├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE ├── README.md ├── TODO.md ├── examples ├── bench │ ├── Cargo.toml │ ├── POST.lua │ ├── README.md │ ├── bench.yaml │ ├── build.rs │ └── src │ │ └── main.rs ├── petstore-expanded │ ├── Cargo.toml │ ├── build.rs │ ├── petstore-expanded.yaml │ └── src │ │ └── lib.rs ├── petstore │ ├── Cargo.toml │ ├── README.md │ ├── build.rs │ ├── petstore.yaml │ ├── src │ │ ├── bin │ │ │ ├── client.rs │ │ │ └── server.rs │ │ └── lib.rs │ └── test.sh ├── quickstart │ ├── Cargo.toml │ ├── README.md │ ├── build.rs │ ├── quickstart.yaml │ └── src │ │ ├── lib.rs │ │ └── main.rs └── tutorial │ ├── Cargo.toml │ ├── README.md │ ├── build.rs │ ├── spec.yaml │ └── src │ ├── lib.rs │ └── main.rs ├── hsr-codegen ├── Cargo.toml ├── NOTE.md ├── README.md ├── src │ ├── bin │ │ └── cli.rs │ ├── lib.rs │ ├── route.rs │ └── walk.rs ├── tests │ └── codegen.rs └── ui-template.html ├── hsr ├── Cargo.toml └── src │ └── lib.rs └── test ├── Cargo.toml ├── README.md ├── build.rs ├── src ├── lib.rs └── main.rs └── test-spec.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | Cargo.lock 2 | target 3 | **/*.rs.bk 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Release 0.4.0 4 | 5 | * Update various dependencies, most notably `actix-web 4` 6 | * Switch formatter to `rustfmt-wrapper` 7 | * Bump editions to `2021` 8 | 9 | ## Release 0.3.0 10 | 11 | * Huuuge refactor to allow more precise type definitions. 12 | - AllOf, AnyOf, OneOf now work 13 | - All the generated names are better 14 | - 'title' is supported 15 | - Much better error checking 16 | 17 | * Added more comprehensive set of tests 18 | 19 | * Added a basic tutorial 20 | 21 | ## Release 0.2.0 22 | 23 | * Convert to `actix 2.0` and `async/await` 24 | 25 | * Added support for HTTP verbs other than GET and POST 26 | 27 | * Added HTTPS support 28 | 29 | * Added simple benchmark example 30 | 31 | * `serve` function takes `hsr::Config` rather than just `Url` 32 | 33 | ## Release 0.1.0 - 2019-08-14 34 | 35 | * First public release 36 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "hsr", 4 | "hsr-codegen", 5 | "test", 6 | "examples/quickstart", 7 | "examples/petstore", 8 | "examples/petstore-expanded", 9 | "examples/bench", 10 | "examples/tutorial", 11 | ] 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Alex Whitney 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HSR 2 | 3 | Build fast HTTP apis fast, with Rust, and [OpenAPI](https://swagger.io/docs/specification/about/) 4 | 5 | * Define your API as an [OpenAPI 3.0](https://github.com/OAI/OpenAPI-Specification) spec 6 | * HSR will code-gen a server/client interface 7 | * Implement your interface (simple, safe, strongly typed!) 8 | * Run! 'If it compiles, it works!' 9 | 10 | ## Docs 11 | 12 | ### Quickstart 13 | 14 | Take a look at the [quickstart example](examples/quickstart). It contains the 15 | minimum boilerplate needed to get up and running. 16 | 17 | ### Tutorial 18 | 19 | Read the [tutorial](examples/tutorial) for a step-by-step guide to get up and running. 20 | 21 | ### Less Quick Start 22 | 23 | Take a look at the [petstore example](examples/petstore) for a more complex example 24 | with a mocked database backend. 25 | 26 | ## Features 27 | 28 | * HTTP server 29 | * HTTP client 30 | * Type-safe path/query/json handling 31 | * Supports all HTTP verbs 32 | * High performance 33 | * Based on `async/await` and `actix-web 4.2.1` 34 | 35 | ## FAQ 36 | 37 | **What's the difference between this and [swagger-rs](https://github.com/Metaswitch/swagger-rs)?** 38 | 39 | I haven't used `swagger-rs`, however the major difference is that `hsr` is pure Rust, 40 | whereas `swagger-rs` takes advantage of an existing code-generator written in Java. 41 | That means that the `swagger-rs` is more mature likely much more correct, 42 | `hsr` is much easier to use and is seamlessly integrated into typical Rust workflow. 43 | 44 | **What do you mean, 'fast'?** 45 | 46 | It uses [Actix-Web](https://github.com/actix/actix-web) under the hood, rated as one of the 47 | fastest web frameworks by [techempower](https://www.techempower.com/benchmarks/#section=data-r18&hw=ph&test=fortune). 48 | `hsr` imposes very little overhead on top. 49 | 50 | As a simple and not-very-scientific benchmark, on my laptop (X1 Carbon 6th Gen) 51 | I measured around: 52 | 53 | * 120,000 requests/second for an empty GET request 54 | * 100,000 requests/second for a POST request with a JSON roundtrip 55 | 56 | Try it yourself! See the [bench example](/examples/bench). 57 | 58 | **Why the name?** 59 | 60 | I like fast trains. 61 | 62 | ## License 63 | 64 | MIT 65 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ## Roadmap 2 | 3 | - [x] Use async in trait (partial - uses the amazing [`async-trait`](https://github.com/dtolnay/async-trait)) 4 | - [x] Works on stable Rust 5 | - [x] Benchmarks 6 | - [x] Full test spec 7 | - [ ] HTTPS 8 | - [ ] Support headers 9 | - [ ] Support default values 10 | - [ ] Support security scopes 11 | - [ ] Advanced server configuration (with middleware etc) 12 | - [ ] support JSON (not just YAML) schema 13 | - [ ] Tutorial Pt II 14 | - [ ] Return content-types other than JSON 15 | - [ ] Auto-generate a client binary 16 | - [ ] Set up CI (inc proper structuring of test suite) 17 | -------------------------------------------------------------------------------- /examples/bench/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "bench" 3 | version = "0.0.0" 4 | authors = ["Alex Whitney "] 5 | edition = "2021" 6 | 7 | [build-dependencies] 8 | hsr-codegen = { path = "../../hsr-codegen" } 9 | 10 | [dependencies] 11 | hsr = { path = "../../hsr" } 12 | serde = "1.0.147" 13 | actix-rt = "2.7.0" 14 | -------------------------------------------------------------------------------- /examples/bench/POST.lua: -------------------------------------------------------------------------------- 1 | wrk.method = "POST" 2 | wrk.body = "{\"key1\": 1.234, \"key2\": \"ABCDE\"}" 3 | wrk.headers["Content-Type"] = "application/json" 4 | -------------------------------------------------------------------------------- /examples/bench/README.md: -------------------------------------------------------------------------------- 1 | ## Benchmark 2 | 3 | This benchmark features just endpoints, a one basic `GET` and a basic `POST`. 4 | 5 | To run, you will need to install `wrk2` 6 | 7 | ``` sh 8 | # start the server 9 | cargo run --release 10 | ``` 11 | Separate terminal: 12 | 13 | ``` sh 14 | # get benchmark 15 | wrk -c10 -d10 -t4 -R 130000 http://localhost:8000/bench 16 | 17 | # post benchmark 18 | wrk -c10 -d10 -t4 -R 130000 -s POST.lua http://localhost:8000/bench 19 | ``` 20 | You may need to adjust `-R ` until you max out your cpus. 21 | 22 | 23 | ## Results 24 | 25 | 26 | Get: 27 | ``` sh 28 | ➜ wrk -c10 -d10 -t4 -R 130000 http://localhost:8000/bench 29 | Running 10s test @ http://localhost:8000/bench 30 | 4 threads and 10 connections 31 | Thread Stats Avg Stdev Max +/- Stdev 32 | Latency 294.43ms 233.42ms 809.47ms 58.61% 33 | Req/Sec -nan -nan 0.00 0.00% 34 | 1196889 requests in 10.00s, 85.61MB read 35 | Requests/sec: 119694.82 36 | Transfer/sec: 8.56MB 37 | ``` 38 | 39 | Post: 40 | 41 | ``` sh 42 | ➜ wrk -c10 -d10 -t4 -R 130000 -s POST.lua http://localhost:8000/bench 43 | Running 10s test @ http://localhost:8000/bench 44 | 4 threads and 10 connections 45 | Thread Stats Avg Stdev Max +/- Stdev 46 | Latency 943.52ms 612.25ms 2.14s 57.49% 47 | Req/Sec -nan -nan 0.00 0.00% 48 | 1024410 requests in 10.00s, 133.84MB read 49 | Requests/sec: 102446.34 50 | Transfer/sec: 13.38MB 51 | 52 | ``` 53 | -------------------------------------------------------------------------------- /examples/bench/bench.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: 1.0.0 4 | title: Benchmark 5 | servers: 6 | - url: http://localhost:8000 7 | 8 | paths: 9 | /bench: 10 | get: 11 | operationId: basic_get 12 | responses: 13 | '200': 14 | description: Simple get 15 | 16 | post: 17 | operationId: basic_post 18 | requestBody: 19 | required: true 20 | content: 21 | application/json: 22 | schema: 23 | $ref: '#/components/schemas/Payload' 24 | responses: 25 | '200': 26 | description: Simple post with payload 27 | content: 28 | application/json: 29 | schema: 30 | $ref: "#/components/schemas/Payload" 31 | 32 | components: 33 | schemas: 34 | Payload: 35 | required: 36 | - key1 37 | - key2 38 | properties: 39 | key1: 40 | type: number 41 | key2: 42 | type: string 43 | -------------------------------------------------------------------------------- /examples/bench/build.rs: -------------------------------------------------------------------------------- 1 | use hsr_codegen; 2 | use std::io::Write; 3 | 4 | fn main() { 5 | let code = hsr_codegen::generate_from_yaml_file("bench.yaml").expect("Generation failure"); 6 | 7 | let out_dir = std::env::var("OUT_DIR").unwrap(); 8 | let dest_path = std::path::Path::new(&out_dir).join("api.rs"); 9 | let mut f = std::fs::File::create(&dest_path).unwrap(); 10 | 11 | write!(f, "{}", code).unwrap(); 12 | } 13 | -------------------------------------------------------------------------------- /examples/bench/src/main.rs: -------------------------------------------------------------------------------- 1 | mod api { 2 | include!(concat!(env!("OUT_DIR"), "/api.rs")); 3 | } 4 | 5 | struct Api; 6 | 7 | #[hsr::async_trait::async_trait(?Send)] 8 | impl api::BenchmarkApi for Api { 9 | async fn basic_get(&self) -> api::BasicGet { 10 | api::BasicGet::Ok 11 | } 12 | 13 | async fn basic_post(&self, payload: api::Payload) -> api::BasicPost { 14 | api::BasicPost::Ok(payload) 15 | } 16 | } 17 | 18 | #[actix_rt::main] 19 | async fn main() -> std::io::Result<()> { 20 | let uri: hsr::Url = "http://127.0.0.1:8000".parse().unwrap(); 21 | println!("Serving at '{}'", uri); 22 | api::server::serve(Api, hsr::Config::with_host(uri)).await 23 | } 24 | -------------------------------------------------------------------------------- /examples/petstore-expanded/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "petstore-expanded" 3 | version = "0.1.0" 4 | authors = ["Alex Whitney "] 5 | edition = "2021" 6 | 7 | [build-dependencies] 8 | hsr-codegen = { path = "../../hsr-codegen" } 9 | 10 | [dependencies] 11 | hsr = { path = "../../hsr" } 12 | serde = { version = "1.0.147", features = ['derive'] } 13 | regex = "1.7.0" 14 | rand = "0.8.5" 15 | env_logger = "0.9.3" 16 | 17 | [features] 18 | pretty = ["hsr-codegen/pretty"] 19 | -------------------------------------------------------------------------------- /examples/petstore-expanded/build.rs: -------------------------------------------------------------------------------- 1 | use hsr_codegen; 2 | use std::env; 3 | use std::fs::File; 4 | use std::io::Write; 5 | use std::path::Path; 6 | 7 | fn main() { 8 | let code = 9 | hsr_codegen::generate_from_yaml_file("petstore-expanded.yaml").expect("Generation failure"); 10 | 11 | let out_dir = env::var("OUT_DIR").unwrap(); 12 | let dest_path = Path::new(&out_dir).join("api.rs"); 13 | let mut f = File::create(&dest_path).unwrap(); 14 | 15 | write!(f, "{}", code).unwrap(); 16 | } 17 | -------------------------------------------------------------------------------- /examples/petstore-expanded/petstore-expanded.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: 1.0.0 4 | title: Swagger Petstore 5 | description: A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification 6 | termsOfService: http://swagger.io/terms/ 7 | contact: 8 | name: Swagger API Team 9 | email: apiteam@swagger.io 10 | url: http://swagger.io 11 | license: 12 | name: Apache 2.0 13 | url: https://www.apache.org/licenses/LICENSE-2.0.html 14 | servers: 15 | - url: http://petstore.swagger.io/api 16 | paths: 17 | /pets: 18 | get: 19 | description: Returns all pets from the system that the user has access to 20 | operationId: find_pets 21 | parameters: 22 | - name: tags 23 | in: query 24 | description: tags to filter by 25 | required: false 26 | style: form 27 | schema: 28 | type: array 29 | items: 30 | type: string 31 | - name: limit 32 | in: query 33 | description: maximum number of results to return 34 | required: false 35 | schema: 36 | type: integer 37 | format: int32 38 | responses: 39 | '200': 40 | description: pet response 41 | content: 42 | application/json: 43 | schema: 44 | type: array 45 | items: 46 | $ref: '#/components/schemas/Pet' 47 | default: 48 | description: unexpected error 49 | content: 50 | application/json: 51 | schema: 52 | $ref: '#/components/schemas/Error' 53 | post: 54 | description: Creates a new pet in the store. Duplicates are allowed 55 | operationId: add_pet 56 | requestBody: 57 | description: Pet to add to the store 58 | required: true 59 | content: 60 | application/json: 61 | schema: 62 | $ref: '#/components/schemas/NewPet' 63 | responses: 64 | '200': 65 | description: pet response 66 | content: 67 | application/json: 68 | schema: 69 | $ref: '#/components/schemas/Pet' 70 | default: 71 | description: unexpected error 72 | content: 73 | application/json: 74 | schema: 75 | $ref: '#/components/schemas/Error' 76 | /pets/{id}: 77 | get: 78 | description: Returns a user based on a single ID, if the user does not have access to the pet 79 | operationId: find_pet_by_id 80 | parameters: 81 | - name: id 82 | in: path 83 | description: ID of pet to fetch 84 | required: true 85 | schema: 86 | type: integer 87 | format: int64 88 | responses: 89 | '200': 90 | description: pet response 91 | content: 92 | application/json: 93 | schema: 94 | $ref: '#/components/schemas/Pet' 95 | default: 96 | description: unexpected error 97 | content: 98 | application/json: 99 | schema: 100 | $ref: '#/components/schemas/Error' 101 | delete: 102 | description: deletes a single pet based on the ID supplied 103 | operationId: delete_pet 104 | parameters: 105 | - name: id 106 | in: path 107 | description: ID of pet to delete 108 | required: true 109 | schema: 110 | type: integer 111 | format: int64 112 | responses: 113 | '204': 114 | description: pet deleted 115 | default: 116 | description: unexpected error 117 | content: 118 | application/json: 119 | schema: 120 | $ref: '#/components/schemas/Error' 121 | components: 122 | schemas: 123 | Pet: 124 | allOf: 125 | - $ref: '#/components/schemas/NewPet' 126 | - required: 127 | - id 128 | properties: 129 | id: 130 | type: integer 131 | format: int64 132 | 133 | NewPet: 134 | required: 135 | - name 136 | properties: 137 | name: 138 | type: string 139 | tag: 140 | type: string 141 | 142 | Error: 143 | required: 144 | - code 145 | - message 146 | properties: 147 | code: 148 | type: integer 149 | format: int32 150 | message: 151 | type: string 152 | -------------------------------------------------------------------------------- /examples/petstore-expanded/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod api { 2 | include!(concat!(env!("OUT_DIR"), "/api.rs")); 3 | } 4 | -------------------------------------------------------------------------------- /examples/petstore/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "petstore" 3 | version = "0.0.0" 4 | authors = ["Alex Whitney "] 5 | edition = "2021" 6 | 7 | [build-dependencies] 8 | # hsr-codegen = { path = "../../hsr-codegen" } 9 | hsr-codegen = { path = "../../hsr-codegen" } 10 | 11 | [[bin]] 12 | name = "petstore-server" 13 | path = "src/bin/server.rs" 14 | 15 | [[bin]] 16 | name = "petstore-client" 17 | path = "src/bin/client.rs" 18 | 19 | [dependencies] 20 | env_logger = "0.9.3" 21 | hsr = { path = "../../hsr" } 22 | rand = "0.8.5" 23 | regex = "1.7.0" 24 | serde = "1.0.147" 25 | actix-rt = "2.7.0" 26 | 27 | [features] 28 | pretty = ["hsr-codegen/pretty"] 29 | -------------------------------------------------------------------------------- /examples/petstore/README.md: -------------------------------------------------------------------------------- 1 | # Petstore Demo 2 | 3 | * The API is specified in `petstore.yaml`. 4 | * The API is generated in `build.rs`. 5 | * The server implementation is `src/lib.rs`. 6 | 7 | There are two binaries. `cargo run --bin petstore-server` launches the server, 8 | `cargo run --bin petstore-client` launches a client and tests various server endpoints. 9 | 10 | Execute `./test.sh` to run a demo. 11 | 12 | Be sure to run `cargo doc --open` to inspect the generated code! 13 | 14 | (Note that the server code has been programmed to randomly fail one-in-twenty times. 15 | Just like a real server!) 16 | -------------------------------------------------------------------------------- /examples/petstore/build.rs: -------------------------------------------------------------------------------- 1 | use hsr_codegen; 2 | use std::env; 3 | use std::fs::File; 4 | use std::io::Write; 5 | use std::path::Path; 6 | 7 | fn main() { 8 | let code = hsr_codegen::generate_from_yaml_file("petstore.yaml").expect("Generation failure"); 9 | 10 | let out_dir = env::var("OUT_DIR").unwrap(); 11 | let dest_path = Path::new(&out_dir).join("api.rs"); 12 | let mut f = File::create(&dest_path).unwrap(); 13 | 14 | write!(f, "{}", code).unwrap(); 15 | println!("cargo:rerun-if-changed=petstore.yaml"); 16 | } 17 | -------------------------------------------------------------------------------- /examples/petstore/petstore.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: 1.0.0 4 | title: Petstore 5 | license: 6 | name: MIT 7 | servers: 8 | - url: http://localhost:8000 9 | 10 | paths: 11 | /pets: 12 | get: 13 | summary: List all pets 14 | operationId: get_all_pets 15 | tags: 16 | - pets 17 | parameters: 18 | - name: limit 19 | in: query 20 | description: How many items to return at one time (max 100) 21 | required: true 22 | schema: 23 | type: integer 24 | format: int32 25 | - name: filter 26 | in: query 27 | description: regex by which to filter pet names 28 | required: false 29 | schema: 30 | type: string 31 | responses: 32 | '200': 33 | description: A paged array of pets 34 | content: 35 | application/json: 36 | schema: 37 | $ref: "#/components/schemas/Pets" 38 | '400': 39 | description: Invalid filter regex 40 | 41 | post: 42 | summary: Create a pet 43 | operationId: create_pet 44 | tags: 45 | - pets 46 | requestBody: 47 | description: Pet to add to the store 48 | required: true 49 | content: 50 | application/json: 51 | schema: 52 | $ref: '#/components/schemas/NewPet' 53 | responses: 54 | '201': 55 | description: pet created 56 | '403': 57 | description: some forbidden 58 | '409': 59 | description: some conflict 60 | content: 61 | application/json: 62 | schema: 63 | $ref: "#/components/schemas/SomeConflict" 64 | default: 65 | description: unexpected error 66 | content: 67 | application/json: 68 | schema: 69 | $ref: "#/components/schemas/Error" 70 | 71 | /pets/{pet_id}: 72 | get: 73 | summary: Info for a specific pet 74 | operationId: get_pet 75 | tags: 76 | - pets 77 | parameters: 78 | - name: pet_id 79 | in: path 80 | required: true 81 | description: The id of the pet to retrieve 82 | schema: 83 | type: integer 84 | responses: 85 | '200': 86 | description: Pet retrieved 87 | content: 88 | application/json: 89 | schema: 90 | $ref: "#/components/schemas/Pet" 91 | '404': 92 | description: Pet not found 93 | default: 94 | description: unexpected error 95 | content: 96 | application/json: 97 | schema: 98 | $ref: "#/components/schemas/Error" 99 | 100 | delete: 101 | summary: Delete a pet 102 | operationId: delete_pet 103 | tags: 104 | - pets 105 | parameters: 106 | - name: pet_id 107 | in: path 108 | required: true 109 | description: The id of the pet to retrieve 110 | schema: 111 | type: integer 112 | responses: 113 | '204': 114 | description: Pet deleted 115 | '404': 116 | description: Pet not found 117 | default: 118 | description: unexpected error 119 | content: 120 | application/json: 121 | schema: 122 | $ref: "#/components/schemas/Error" 123 | 124 | components: 125 | schemas: 126 | Pet: 127 | description: A cat or a dog or a mouse or a rabbit 128 | required: 129 | - id 130 | - name 131 | properties: 132 | id: 133 | description: Unique identifier 134 | type: integer 135 | format: int64 136 | name: 137 | description: Name of pet 138 | type: string 139 | tag: 140 | type: string 141 | 142 | NewPet: 143 | description: A new pet! Fluffy and and cute 144 | required: 145 | - name 146 | properties: 147 | name: 148 | description: Name of pet 149 | type: string 150 | tag: 151 | type: string 152 | 153 | Pets: 154 | description: Many pets! 155 | type: array 156 | items: 157 | $ref: "#/components/schemas/Pet" 158 | 159 | Error: 160 | description: Bad, wrong, make feel sad 161 | required: 162 | - code 163 | - message 164 | properties: 165 | code: 166 | type: integer 167 | format: int32 168 | message: 169 | type: string 170 | 171 | SomeConflict: 172 | description: We need some conflict resolution 173 | required: 174 | - message 175 | properties: 176 | message: 177 | type: string 178 | -------------------------------------------------------------------------------- /examples/petstore/src/bin/client.rs: -------------------------------------------------------------------------------- 1 | use std::error::Error; 2 | 3 | use hsr::futures::TryFutureExt; 4 | use petstore::{api, client::Client, NewPet}; 5 | 6 | // This CLI uses the auto-generated API client to test various server endpoints 7 | 8 | #[actix_rt::main] 9 | async fn main() -> Result<(), Box> { 10 | env_logger::init(); 11 | let client = Client::new("http://localhost:8000".parse().unwrap()); 12 | run(&client).await 13 | } 14 | 15 | fn dbg(v: impl std::fmt::Debug) -> String { 16 | format!("{:?}", v) 17 | } 18 | 19 | async fn run(client: &Client) -> Result<(), Box> { 20 | // Create two pets 21 | let pet1 = NewPet { 22 | name: "Alex the Goat".into(), 23 | tag: None, 24 | }; 25 | let pet2 = NewPet { 26 | name: "Bob the Badger".into(), 27 | tag: None, 28 | }; 29 | 30 | let _ = client.create_pet(pet1).map_err(dbg).await?; 31 | let _ = client.create_pet(pet2).map_err(dbg).await?; 32 | 33 | // Fetch a pet 34 | let pet = client.get_pet(0).await.map_err(dbg)?; 35 | println!("Got pet: {:?}", pet); 36 | 37 | // Fetch all pets 38 | let pets = client.get_all_pets(10, None).map_err(dbg).await?; 39 | println!("Got pets: {:?}", pets); 40 | 41 | // Fetch a pet that doesn't exist 42 | // Note the custom return error 43 | if let api::GetPet::NotFound = client.get_pet(500).await? { 44 | () 45 | } else { 46 | panic!("Not not found") 47 | }; 48 | 49 | // Empty the DB 50 | let _ = client.delete_pet(0).map_err(dbg).await?; 51 | let _ = client.delete_pet(0).map_err(dbg).await?; 52 | if let api::DeletePet::NotFound = client.delete_pet(0).await? { 53 | () 54 | } else { 55 | panic!("Not not found") 56 | }; 57 | 58 | Ok(()) 59 | } 60 | -------------------------------------------------------------------------------- /examples/petstore/src/bin/server.rs: -------------------------------------------------------------------------------- 1 | use petstore::{server, Api}; 2 | 3 | // Serve the API. 4 | // 5 | // Navigate your browser to http://localhost:8000/ui.html to see 6 | // the API as rendered by [Swagger UI](https://github.com/swagger-api/swagger-ui) 7 | #[actix_rt::main] 8 | async fn main() -> std::io::Result<()> { 9 | env_logger::init(); 10 | let uri = "http://127.0.0.1:8000".parse().unwrap(); 11 | server::serve::(Api::new(), hsr::Config::with_host(uri)).await 12 | } 13 | -------------------------------------------------------------------------------- /examples/petstore/src/lib.rs: -------------------------------------------------------------------------------- 1 | use hsr::futures::lock; 2 | use regex::Regex; 3 | 4 | pub mod api { 5 | include!(concat!(env!("OUT_DIR"), "/api.rs")); 6 | } 7 | 8 | pub use api::{client, server, Error, NewPet, Pet, PetstoreApi}; 9 | 10 | impl Pet { 11 | fn new(id: i64, name: String, tag: Option) -> Pet { 12 | Pet { id, name, tag } 13 | } 14 | } 15 | 16 | // Define an error type to be used internally 17 | pub enum InternalError { 18 | BadConnection, 19 | ParseFailure, 20 | } 21 | 22 | type Result = std::result::Result; 23 | 24 | // We define an object against which to implement our API trait 25 | pub struct Api { 26 | // our database is just some Vec 27 | database: lock::Mutex>, 28 | } 29 | 30 | // // We simulate some kind of database interactions 31 | impl Api { 32 | pub fn new() -> Self { 33 | Api { 34 | database: lock::Mutex::new(vec![]), 35 | } 36 | } 37 | 38 | async fn connect_db(&self) -> Result>> { 39 | if rand::random::() > 0.95 { 40 | Err(InternalError::BadConnection) 41 | } else { 42 | Ok(self.database.lock().await) 43 | } 44 | } 45 | 46 | // Define some basic CRUD operations for our database 47 | 48 | async fn add_pet(&self, new_pet: NewPet) -> Result { 49 | let mut db = self.connect_db().await?; 50 | let id = db.len(); 51 | let new_pet = Pet::new(id as i64, new_pet.name, new_pet.tag); 52 | db.push(new_pet); 53 | Ok(id) 54 | } 55 | 56 | async fn lookup_pet(&self, id: usize) -> Result> { 57 | let db = self.connect_db().await?; 58 | Ok(db.get(id).cloned()) 59 | } 60 | 61 | async fn remove_pet(&self, id: usize) -> Result> { 62 | let mut db = self.connect_db().await?; 63 | if id < db.len() { 64 | Ok(Some(db.remove(id))) 65 | } else { 66 | Ok(None) 67 | } 68 | } 69 | 70 | async fn list_all_pets(&self) -> Result> { 71 | let db = self.connect_db().await?; 72 | Ok(db.clone()) 73 | } 74 | 75 | // every server needs a status check! 76 | fn server_health_check(&self) -> Result<()> { 77 | Ok(()) 78 | } 79 | } 80 | 81 | // The meat of the example. We fulfill the server interface as defined by the 82 | // `petstore.yaml` OpenAPI file by implementing the PetstoreApi trait. 83 | // 84 | // The trait function definitions may not be obvious just from reading the spec, 85 | // in which case it will be helpful to run `cargo doc` to see the trait rendered 86 | // by `rustdoc`. (Of course, if the trait is not implemented correcty, it will 87 | // not compile). 88 | #[hsr::async_trait::async_trait(?Send)] 89 | impl PetstoreApi for Api { 90 | async fn get_all_pets(&self, limit: i64, filter: Option) -> api::GetAllPets { 91 | let regex = if let Some(filter) = filter { 92 | match Regex::new(&filter) { 93 | Ok(re) => re, 94 | Err(_) => return api::GetAllPets::BadRequest, 95 | } 96 | } else { 97 | Regex::new(".?").unwrap() 98 | }; 99 | let pets = match self.list_all_pets().await { 100 | Ok(p) => p, 101 | Err(_) => return api::GetAllPets::BadRequest, 102 | }; 103 | api::GetAllPets::Ok( 104 | pets.into_iter() 105 | .take(limit as usize) 106 | .filter(|p| regex.is_match(&p.name)) 107 | .collect(), 108 | ) 109 | } 110 | 111 | async fn create_pet(&self, new_pet: NewPet) -> api::CreatePet { 112 | let res: Result<()> = async { 113 | let () = self.server_health_check()?; 114 | let _ = self.add_pet(new_pet).await?; // TODO return usize 115 | Ok(()) 116 | } 117 | .await; 118 | match res { 119 | Ok(()) => api::CreatePet::Created, 120 | Err(_) => api::CreatePet::Forbidden, 121 | } 122 | } 123 | 124 | async fn get_pet(&self, pet_id: i64) -> api::GetPet { 125 | match self.lookup_pet(pet_id as usize).await { 126 | Ok(Some(pet)) => api::GetPet::Ok(pet), 127 | Ok(None) => api::GetPet::NotFound, 128 | Err(_) => api::GetPet::Default { 129 | status_code: 500, 130 | body: api::Error { 131 | code: 12345, 132 | message: "Something went wrong".into(), 133 | }, 134 | }, 135 | } 136 | } 137 | 138 | async fn delete_pet(&self, pet_id: i64) -> api::DeletePet { 139 | match self.remove_pet(pet_id as usize).await { 140 | Ok(Some(_)) => api::DeletePet::NoContent, 141 | Ok(None) => api::DeletePet::NotFound, 142 | Err(_) => api::DeletePet::Default { 143 | status_code: 500, 144 | body: api::Error { 145 | code: 12345, 146 | message: "Something went wrong".into(), 147 | }, 148 | }, 149 | } 150 | } 151 | } 152 | 153 | // That's it! Your server is ready. See bin/server.rs to see how to launch it 154 | -------------------------------------------------------------------------------- /examples/petstore/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | cargo build 5 | 6 | echo "Starting server" 7 | killall petstore-server -q && true 8 | ../../target/debug/petstore-server & 9 | sleep 0.5 10 | 11 | echo "Testing server with client" 12 | ../../target/debug/petstore-client 13 | 14 | # check we can see the api 15 | curl -s --fail http://localhost:8000/ui.html > /dev/null 16 | curl -s --fail http://localhost:8000/spec.json > /dev/null 17 | 18 | echo "Tests passed" 19 | 20 | killall petstore-server 21 | -------------------------------------------------------------------------------- /examples/quickstart/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "quickstart" 3 | version = "0.1.0" 4 | authors = ["Alex Whitney "] 5 | edition = "2021" 6 | 7 | [build-dependencies] 8 | hsr-codegen = { path = "../../hsr-codegen", features = ["pretty"] } 9 | 10 | [dependencies] 11 | hsr = { path = "../../hsr" } 12 | serde = "1.0.147" 13 | env_logger = "0.9.3" 14 | actix-rt = "2.7.0" 15 | 16 | [features] 17 | pretty = ["hsr-codegen/pretty"] 18 | -------------------------------------------------------------------------------- /examples/quickstart/README.md: -------------------------------------------------------------------------------- 1 | ## Quickstart 2 | 3 | This is a very bare-bones server/client to get you up and running. 4 | 5 | Execute `cargo run` to launch a basic demo. 6 | 7 | It will start a simple server in one thread, access it through a client, print the result and exit. 8 | 9 | Amazed, impressed? Then, take a look at the code! 10 | 11 | * The API is specified in `quickstart.yaml` 12 | * The API is generated in `build.rs` 13 | * The server implementation is `src/main.rs` 14 | -------------------------------------------------------------------------------- /examples/quickstart/build.rs: -------------------------------------------------------------------------------- 1 | use hsr_codegen; 2 | use std::io::Write; 3 | 4 | fn main() { 5 | let code = hsr_codegen::generate_from_yaml_file("quickstart.yaml").expect("Generation failure"); 6 | 7 | let out_dir = std::env::var("OUT_DIR").unwrap(); 8 | let dest_path = std::path::Path::new(&out_dir).join("api.rs"); 9 | let mut f = std::fs::File::create(&dest_path).unwrap(); 10 | 11 | write!(f, "{}", code).unwrap(); 12 | println!("cargo:rerun-if-changed=quickstart.yaml"); 13 | } 14 | -------------------------------------------------------------------------------- /examples/quickstart/quickstart.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: 1.0.0 4 | title: Quickstart 5 | servers: 6 | - url: http://localhost:8000 7 | 8 | paths: 9 | /{name}: 10 | get: 11 | summary: Quickstart 12 | operationId: greet 13 | parameters: 14 | - name: name 15 | in: path 16 | required: true 17 | description: User name 18 | schema: 19 | type: string 20 | responses: 21 | '200': 22 | description: Hello 23 | content: 24 | application/json: 25 | schema: 26 | $ref: "#/components/schemas/Hello" 27 | 28 | components: 29 | schemas: 30 | Hello: 31 | required: 32 | - name 33 | - greeting 34 | properties: 35 | name: 36 | type: string 37 | greeting: 38 | type: string 39 | -------------------------------------------------------------------------------- /examples/quickstart/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod api { 2 | include!(concat!(env!("OUT_DIR"), "/api.rs")); 3 | } 4 | -------------------------------------------------------------------------------- /examples/quickstart/src/main.rs: -------------------------------------------------------------------------------- 1 | use quickstart::api::{client, server, Greet, Hello, QuickstartApi}; 2 | 3 | struct Api; 4 | 5 | #[hsr::async_trait::async_trait(?Send)] 6 | impl QuickstartApi for Api { 7 | async fn greet(&self, name: String) -> Greet { 8 | Greet::Ok(Hello { 9 | name, 10 | greeting: "Pleased to meet you".into(), 11 | }) 12 | } 13 | } 14 | 15 | #[actix_rt::main] 16 | async fn main() { 17 | env_logger::init(); 18 | 19 | let uri: hsr::Url = "http://127.0.0.1:8000".parse().unwrap(); 20 | let uri2 = uri.clone(); 21 | 22 | std::thread::spawn(move || { 23 | println!("Serving at '{}'", uri); 24 | let system = hsr::actix_rt::System::new(); 25 | let server = server::serve(Api, hsr::Config::with_host(uri)); 26 | system.block_on(server).unwrap(); 27 | }); 28 | 29 | std::thread::sleep(std::time::Duration::from_millis(100)); 30 | 31 | let client = client::Client::new(uri2); 32 | println!("Querying server"); 33 | let greeting = client.greet("Bobert".to_string()).await; 34 | println!("{:?}", greeting); 35 | } 36 | -------------------------------------------------------------------------------- /examples/tutorial/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tutorial" 3 | version = "0.1.0" 4 | authors = ["Alex Whitney "] 5 | edition = "2021" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | hsr = { path = "../../hsr" } 11 | serde = "1.0.147" 12 | actix-rt = "2.7.0" 13 | 14 | [build-dependencies] 15 | hsr-codegen = { path = "../../hsr-codegen" } 16 | -------------------------------------------------------------------------------- /examples/tutorial/README.md: -------------------------------------------------------------------------------- 1 | # Tuturial 2 | 3 | `hsr` is a code-generator. It takes an OpenAPIv3 spec as an input and generates Rust 4 | as it's output. It can massively reduce the boilerplate and general annoyance of writing 5 | an HTTP API, either for an internet-facing web service or an internal micro (or macro!) service. 6 | And becuase it is just a thin layer on top of `actix-web`, performance is excellent. 7 | 8 | OpenAPIv3, aka swagger, is a popular format for specifying an HTTP API. 9 | If you aren't familiar with OpenAPI, I recommend having at least a 10 | [quick skim of the the docs](https://swagger.io/docs/specification/about/) before proceeding. 11 | 12 | The generated Rust code presents an interface (i.e. a trait) which the user should implement. 13 | Once that trait is implemented, the server is ready to go. `hsr` takes care of 14 | bootstrapping the server, url routing, serializing the inputs and outputs and other 15 | boilerplate. 16 | 17 | Rust's powerful type system makes this a particularly nice workflow because 'if it compiles, 18 | it works'. Suppose you need to modify or add an endpoint, you simply modify you API spec 19 | (usually a `.yaml` file) and recompile. `rustc` will most likely throw a bunch of type 20 | errors, you fix them, and you're done. 21 | 22 | Right, enough talk, lets get started. Make a new project. 23 | 24 | ``` sh 25 | cargo new tutorial 26 | cd tutorial 27 | ``` 28 | We'll use the handy [`cargo-edit`](https://crates.io/crates/cargo-edit) to add our 29 | dependencies. 30 | 31 | ``` sh 32 | cargo add -B hsr-codegen # build dependency 33 | cargo add hsr actix-rt serde # runtime dependencies 34 | ``` 35 | 36 | ## Hello HSR! 37 | 38 | First, we'll define an api. Create a `spec.yaml` file containing the following: 39 | ``` yaml 40 | # spec.yaml 41 | openapi: "3.0.0" 42 | info: 43 | version: 0.0.1 44 | title: hsr-tutorial 45 | servers: 46 | - url: http://localhost:8000 47 | 48 | paths: 49 | /hello: 50 | get: 51 | operationId: hello 52 | responses: 53 | '200': 54 | description: Yes, we get it, hello 55 | ``` 56 | 57 | This is just about the simplest possible API. It exposes a single route, `GET /hello`, 58 | to which it responds with a `200 OK`. That's it. Yes, I know it's boring, we'll make it 59 | WACKY later - for now we're just going to build it. 60 | 61 | Create a `build.rs` file in your project root: 62 | 63 | ```rust 64 | // build.rs 65 | 66 | use hsr_codegen; 67 | use std::io::Write; 68 | 69 | fn main() { 70 | let code = hsr_codegen::generate_from_yaml_file("spec.yaml").expect("Generation failure"); 71 | 72 | let out_dir = std::env::var("OUT_DIR").unwrap(); 73 | let dest_path = std::path::Path::new(&out_dir).join("api.rs"); 74 | let mut f = std::fs::File::create(&dest_path).unwrap(); 75 | 76 | write!(f, "{}", code).unwrap(); 77 | // If we alter the spec.yaml, we should rebuild the api 78 | println!("cargo:rerun-if-changed=spec.yaml"); 79 | } 80 | ``` 81 | Now if we run `cargo build`, it does... something. Specifically, we just told Rust 82 | that at build-time it should: 83 | * Open the spec file 84 | * Generate our interface code from the spec 85 | * Find the magic OUT_DIR and write the code to `$OUT_DIR/api.rs` 86 | 87 | Nice! But not very useful as-is. To actually use this code, we need to get it into our 88 | project source. 89 | 90 | Create a file `src/lib.rs` containing the following: 91 | 92 | ```rust 93 | pub mod api { 94 | include!(concat!(env!("OUT_DIR"), "/api.rs")); 95 | } 96 | ``` 97 | Now we can compile! Go! Or, actually, wait a sec. We are going to codegen an interface. 98 | Really, we want to be able to view our API. But viewing the raw code isn't very 99 | enlightening (and Rust doesn't make it easy), instead we view it in `rustdoc`. 100 | ``` rust 101 | $ cargo doc --open 102 | ``` 103 | Ok, this time it did something useful. Inside the `api` module we can see a promising-sounding 104 | things like `client` and `server` modules and an `HsrTutorialApi` trait. 105 | 106 | The `HsrTutorialApi` trait has a rather intimidating definition, something like: 107 | 108 | ``` rust 109 | trait HsrTutorialApi { 110 | fn hello<'life0, 'async_trait>(&'life0 self) -> Pin + 'async_trait>> 111 | where 112 | 'life0: 'async_trait, 113 | Self: 'async_trait; 114 | } 115 | ``` 116 | This is not as complicated as it looks. Basically this trait should be read as: 117 | 118 | ```rust 119 | trait HsrTutorialapi { 120 | async fn hello(&self) -> Hello; 121 | } 122 | ``` 123 | where `Hello` is defined elsewhere. Which we can see closely matches the definition in `spec.yaml`. 124 | 125 | Why does the definition... not look like that? Well, unfortunately "async-in-traits" is not yet 126 | supported [(issue)](https://github.com/rust-lang/rfcs/issues/2739) so for now we work around it 127 | with the amazing [`async-trait`](https://github.com/dtolnay/async-trait), 128 | which however gives us these slightly inscrutable api definitions. 129 | 130 | Lets gloss over this for now and implement the trait. Continuing in `src/lib.rs`: 131 | 132 | ```rust 133 | // src/lib.rs 134 | 135 | /* .. previous code .. */ 136 | 137 | struct Api; 138 | 139 | #[hsr::async_trait::async_trait(?Send)] 140 | impl api::HsrTutorialApi for Api { 141 | async fn hello(&self) -> api::Hello { 142 | api::Hello::Ok 143 | } 144 | } 145 | ``` 146 | Notice that we've used the `async_trait` macro (conveniently re-exported from hsr) 147 | to allow us to implement the trait as we would 'like' it to be written, rather than as it is defined 148 | according to the docs. 149 | 150 | The last step is to serve our api. We can see from the api docs that there is a function 151 | in `tutorial::api::server` with the signature 152 | 153 | ``` rust 154 | pub async fn serve(api: A, cfg: Config) -> std::io::Result<()> 155 | ``` 156 | so let's use that. 157 | 158 | 159 | In `src/main.rs`: 160 | 161 | ``` rust 162 | use tutorial::{api, Api}; 163 | 164 | #[actix_rt::main] 165 | async fn main() -> std::io::Result<()> { 166 | let config = hsr::Config::with_host("http://localhost:8000".parse().unwrap()); 167 | api::server::serve(Api, config).await 168 | } 169 | ``` 170 | 171 | That's it, the webserver is ready. We're going to test it with [`httpie`](https://httpie.org/), which 172 | can be installed with `pip3 install httpie --user`. 173 | 174 | ``` sh 175 | // terminal 1 176 | cargo run 177 | 178 | // terminal 2 179 | ➜ ~ http --print hH :8000/hello 180 | GET /hello HTTP/1.1 181 | # ... 182 | 183 | HTTP/1.1 200 OK 184 | ``` 185 | It lives! 186 | 187 | ## That's not fun, make it fun 188 | 189 | Fine, very nice. What else can this thing do? Let's flex our muscles. 190 | 191 | Add the following to you `spec.yaml`: 192 | 193 | 194 | ```yaml 195 | # spec.yaml 196 | 197 | # ... previous code ... 198 | 199 | /greet/{name}: 200 | get: 201 | operationId: greet 202 | parameters: 203 | - name: name 204 | in: path 205 | required: true 206 | schema: 207 | type: string 208 | - name: obsequiousness 209 | in: query 210 | required: false 211 | schema: 212 | type: integer 213 | responses: 214 | '200': 215 | description: If you can't say something nice... 216 | content: 217 | application/json: 218 | schema: 219 | type: object 220 | required: 221 | - greeting 222 | properties: 223 | greeting: 224 | type: string 225 | lay_it_on_thick: 226 | $ref: '#/components/schemas/LayItOnThick' 227 | 228 | components: 229 | schemas: 230 | LayItOnThick: 231 | type: object 232 | required: 233 | - is_wonderful_person 234 | - is_kind_to_animals 235 | - would_take_to_meet_family 236 | properties: 237 | is_wonderful_person: 238 | type: boolean 239 | is_kind_to_animals: 240 | type: boolean 241 | would_take_to_meet_family: 242 | type: boolean 243 | ``` 244 | 245 | Now if we re-run `cargo doc` and refresh our browser, we have some new goodies. In the trait definition: 246 | 247 | ``` rust 248 | trait HsrTutorialApi { 249 | // .. previous definition 250 | 251 | fn greet<'life0, 'async_trait>(&'life0 self, name: String, obsequiousness_level: Option) 252 | -> Pin + 'async_trait>> 253 | where 254 | 'life0: 'async_trait, 255 | Self: 'async_trait; 256 | } 257 | ``` 258 | ... which we have learned should be read as 259 | 260 | ``` rust 261 | trait HsrTutorialapi { 262 | // ... 263 | 264 | async fn greet(&self, name: String, obsequiosness_level: Option) -> Greet; 265 | } 266 | ``` 267 | 268 | We implement it like so: 269 | 270 | 271 | ``` rust 272 | #[hsr::async_trait::async_trait(?Send)] 273 | impl api::HsrTutorialApi for Api { 274 | // ... previous 275 | 276 | async fn greet(&self, name: String, obsequiousness_level: Option) -> api::Greet { 277 | let obs_lvl = obsequiousness_level.unwrap_or(0); 278 | let lay_it_on_thick = if obs_lvl <= 0 { 279 | None 280 | } else { 281 | Some(api::LayItOnThick { 282 | is_wonderful_person: obs_lvl >= 1, 283 | is_kind_to_animals: obs_lvl >= 2, 284 | would_take_to_meet_family: obs_lvl >= 3, 285 | }) 286 | }; 287 | api::Greet::Ok(api::Greet200 { 288 | greeting: format!("Greetings {}, pleased to meet you", name), 289 | lay_it_on_thick, 290 | }) 291 | } 292 | } 293 | ``` 294 | 295 | That's our new endpoint implemented. Let's try it out: 296 | 297 | ``` sh 298 | ➜ ~ http ":8000/greet/Alex" 299 | HTTP/1.1 200 OK 300 | 301 | { 302 | "greeting": "Greetings Alex, pleased to meet you", 303 | "lay_it_on_thick": null 304 | } 305 | 306 | ➜ ~ http ":8000/greet/Alex?obsequiousness=1" 307 | HTTP/1.1 200 OK 308 | 309 | { 310 | "greeting": "Greetings Alex, pleased to meet you", 311 | "lay_it_on_thick": { 312 | "is_kind_to_animals": false, 313 | "is_wonderful_person": true, 314 | "would_take_to_meet_family": false 315 | } 316 | } 317 | 318 | ➜ ~ http ":8000/greet/Alex?obsequiousness=50" 319 | HTTP/1.1 200 OK 320 | 321 | { 322 | "greeting": "Greetings Alex, pleased to meet you", 323 | "lay_it_on_thick": { 324 | "is_kind_to_animals": true, 325 | "is_wonderful_person": true, 326 | "would_take_to_meet_family": true 327 | } 328 | } 329 | ``` 330 | 331 | Beautiful! This API really knows how make you feel good about yourself. 332 | 333 | That's it for now. Take a look at the `petstore` example for a more complex spec 334 | that implements a somewhat-realistic looking API. 335 | -------------------------------------------------------------------------------- /examples/tutorial/build.rs: -------------------------------------------------------------------------------- 1 | use hsr_codegen; 2 | use std::io::Write; 3 | 4 | fn main() { 5 | let code = hsr_codegen::generate_from_yaml_file("spec.yaml").expect("Generation failure"); 6 | 7 | let out_dir = std::env::var("OUT_DIR").unwrap(); 8 | let dest_path = std::path::Path::new(&out_dir).join("api.rs"); 9 | let mut f = std::fs::File::create(&dest_path).unwrap(); 10 | 11 | write!(f, "{}", code).unwrap(); 12 | println!("cargo:rerun-if-changed=spec.yaml"); 13 | } 14 | -------------------------------------------------------------------------------- /examples/tutorial/spec.yaml: -------------------------------------------------------------------------------- 1 | # spec.yaml 2 | openapi: "3.0.0" 3 | info: 4 | version: 0.0.1 5 | title: hsr-tutorial 6 | servers: 7 | - url: http://localhost:8000 8 | 9 | paths: 10 | /hello: 11 | get: 12 | operationId: hello 13 | responses: 14 | '200': 15 | description: Yes, we get it, hello 16 | 17 | /greet/{name}: 18 | get: 19 | operationId: greet 20 | parameters: 21 | - name: name 22 | in: path 23 | required: true 24 | schema: 25 | type: string 26 | - name: obsequiousness 27 | in: query 28 | required: false 29 | schema: 30 | type: integer 31 | responses: 32 | '200': 33 | description: If you can't say something nice... 34 | content: 35 | application/json: 36 | schema: 37 | type: object 38 | required: 39 | - greeting 40 | properties: 41 | greeting: 42 | type: string 43 | lay_it_on_thick: 44 | $ref: '#/components/schemas/LayItOnThick' 45 | 46 | components: 47 | schemas: 48 | LayItOnThick: 49 | type: object 50 | required: 51 | - is_wonderful_person 52 | - is_kind_to_animals 53 | - would_take_to_meet_family 54 | properties: 55 | is_wonderful_person: 56 | type: boolean 57 | is_kind_to_animals: 58 | type: boolean 59 | would_take_to_meet_family: 60 | type: boolean 61 | -------------------------------------------------------------------------------- /examples/tutorial/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod api { 2 | include!(concat!(env!("OUT_DIR"), "/api.rs")); 3 | } 4 | 5 | pub struct Api; 6 | 7 | #[hsr::async_trait::async_trait(?Send)] 8 | impl api::HsrTutorialApi for Api { 9 | async fn hello(&self) -> api::Hello { 10 | api::Hello::Ok 11 | } 12 | 13 | async fn greet(&self, name: String, obsequiousness_level: Option) -> api::Greet { 14 | let obs_lvl = obsequiousness_level.unwrap_or(0); 15 | let lay_it_on_thick = if obs_lvl <= 0 { 16 | None 17 | } else { 18 | Some(api::LayItOnThick { 19 | is_wonderful_person: obs_lvl >= 1, 20 | is_kind_to_animals: obs_lvl >= 2, 21 | would_take_to_meet_family: obs_lvl >= 3, 22 | }) 23 | }; 24 | api::Greet::Ok(api::Greet200 { 25 | greeting: format!("Greetings {}, pleased to meet you", name), 26 | lay_it_on_thick, 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/tutorial/src/main.rs: -------------------------------------------------------------------------------- 1 | use tutorial::{api, Api}; 2 | 3 | #[actix_rt::main] 4 | async fn main() -> std::io::Result<()> { 5 | let config = hsr::Config::with_host("http://localhost:8000".parse().unwrap()); 6 | api::server::serve(Api, config).await 7 | } 8 | -------------------------------------------------------------------------------- /hsr-codegen/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hsr-codegen" 3 | version = "0.4.0" 4 | authors = ["Alex Whitney "] 5 | edition = "2021" 6 | description = "Build fast HTTP APIs fast, with Rust + OpenAPI" 7 | repository = "https://github.com/adwhit/hsr" 8 | homepage = "https://github.com/adwhit/hsr" 9 | keywords = ["swagger", "openapi", "web", "REST", "actix-web"] 10 | license = "MIT" 11 | readme = "README.md" 12 | 13 | [dependencies] 14 | actix-http = "3.2.2" 15 | derive_more = "0.99.17" 16 | either = "1.8.0" 17 | heck = "0.4.0" 18 | http = "0.2.8" 19 | indexmap = "1.9.1" 20 | log = "0.4.17" 21 | openapiv3 = "1.0.1" 22 | proc-macro2 = "1.0.47" 23 | quote = "1.0.21" 24 | regex = "1.7.0" 25 | serde_json = "1.0.87" 26 | serde_yaml = "0.9.14" 27 | structopt = "0.3.26" 28 | syn = "1.0.103" 29 | thiserror = "1.0.37" 30 | 31 | rustfmt-wrapper = { version = "0.2.0", optional = true } 32 | 33 | [dev-dependencies] 34 | diff = "0.1.13" 35 | env_logger = "0.9.3" 36 | tempdir = "0.3.7" 37 | yansi = "0.5.1" 38 | 39 | [features] 40 | pretty = [ "rustfmt-wrapper" ] 41 | -------------------------------------------------------------------------------- /hsr-codegen/NOTE.md: -------------------------------------------------------------------------------- 1 | 2 | Strategy: Each API path names a type. 3 | 4 | When we try to construct them, we might find that they are concrete types that 5 | need to be created 6 | 7 | e.g. for path 8 | ApiPath(["paths", "/pets/{id}", "DELETE", "reponses", "default", "application/json"]) 9 | => 10 | struct PathPetIdDeleteResponseDefaultJson { 11 | sucess: bool 12 | } 13 | 14 | Or we might be able to simply alias the type to a known type 15 | 16 | // built-in 17 | type PathPetIdDeleteResponseDefaultJson = String 18 | 19 | // generated elsewhere 20 | type PathPetIdDeleteResponseDefaultJson = NewPet 21 | 22 | in any case, we always use the full name when creating our endpoint 23 | 24 | fn delete_pet() -> PathPetIdDeleteResponseDefaultJson; 25 | 26 | That way, we don't have to worry about chasing references around the place 27 | -------------------------------------------------------------------------------- /hsr-codegen/README.md: -------------------------------------------------------------------------------- 1 | # hsr-codegen 2 | 3 | This is the code-generation component of [hsr](http://github.com/adwhit/hsr). 4 | 5 | See the [repository](http://github.com/adwhit/hsr) README to get started. 6 | -------------------------------------------------------------------------------- /hsr-codegen/src/bin/cli.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use structopt::StructOpt; 4 | 5 | use hsr_codegen::generate_from_yaml_file; 6 | 7 | #[derive(Clone, Debug, StructOpt)] 8 | struct Args { 9 | #[structopt(parse(from_os_str))] 10 | spec: PathBuf, 11 | } 12 | 13 | fn main() { 14 | let args = Args::from_args(); 15 | println!("{:?}", args); 16 | 17 | let gen = generate_from_yaml_file(args.spec).unwrap(); 18 | 19 | println!("{}", gen); 20 | } 21 | -------------------------------------------------------------------------------- /hsr-codegen/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![recursion_limit = "256"] 2 | #![allow(unused_imports)] 3 | 4 | use std::convert::TryFrom; 5 | use std::fmt; 6 | use std::fs; 7 | use std::path::Path; 8 | use std::str::FromStr; 9 | 10 | use actix_http::StatusCode; 11 | use derive_more::{Deref, Display}; 12 | use either::Either; 13 | use heck::{ToPascalCase, ToSnakeCase}; 14 | use indexmap::{IndexMap as Map, IndexSet as Set}; 15 | use log::{debug, info}; 16 | use openapiv3::{ 17 | AnySchema, ObjectType, OpenAPI, ReferenceOr, Schema, SchemaData, SchemaKind, 18 | StatusCode as ApiStatusCode, Type as ApiType, 19 | }; 20 | use proc_macro2::{Ident as QIdent, TokenStream}; 21 | use quote::quote; 22 | use regex::Regex; 23 | use thiserror::Error; 24 | 25 | macro_rules! invalid { 26 | ($($arg:tt)+) => ( 27 | return Err(Error::Validation(format!($($arg)+))) 28 | ); 29 | } 30 | 31 | mod route; 32 | mod walk; 33 | 34 | use route::Route; 35 | 36 | const SWAGGER_UI_TEMPLATE: &'static str = include_str!("../ui-template.html"); 37 | 38 | fn ident(s: impl fmt::Display) -> QIdent { 39 | QIdent::new(&s.to_string(), proc_macro2::Span::call_site()) 40 | } 41 | 42 | type SchemaLookup = Map>; 43 | 44 | #[derive(Debug, Error)] 45 | pub enum Error { 46 | #[error("IO Error: {}", _0)] 47 | Io(#[from] std::io::Error), 48 | #[error("Yaml Error: {}", _0)] 49 | Yaml(#[from] serde_yaml::Error), 50 | #[error("Codegen failed: {}", _0)] 51 | BadCodegen(String), 52 | #[error("Bad reference: {}", _0)] 53 | BadReference(String), 54 | #[error("OpenAPI validation failed: {}", _0)] 55 | Validation(String), 56 | } 57 | 58 | pub type Result = std::result::Result; 59 | 60 | /// Unwrap the reference, or fail 61 | /// TODO get rid of this 62 | fn unwrap_ref(item: &ReferenceOr) -> Result<&T> { 63 | match item { 64 | ReferenceOr::Item(item) => Ok(item), 65 | ReferenceOr::Reference { reference } => Err(Error::BadReference(reference.to_string())), 66 | } 67 | } 68 | 69 | /// Fetch reference target via a lookup 70 | fn dereference<'a, T>( 71 | refr: &'a ReferenceOr, 72 | lookup: &'a Map>, 73 | ) -> Result<&'a T> { 74 | match refr { 75 | ReferenceOr::Reference { reference } => lookup 76 | .get(reference) 77 | .ok_or_else(|| Error::BadReference(reference.to_string())) 78 | .and_then(|refr| dereference(refr, lookup)), 79 | ReferenceOr::Item(item) => Ok(item), 80 | } 81 | } 82 | 83 | fn api_trait_name(api: &OpenAPI) -> TypeName { 84 | TypeName::from_str(&format!("{}Api", api.info.title.to_pascal_case())).unwrap() 85 | } 86 | 87 | #[derive(Debug, Clone, Copy, derive_more::Display)] 88 | enum RawMethod { 89 | Get, 90 | Post, 91 | Delete, 92 | Put, 93 | Patch, 94 | Options, 95 | Head, 96 | Trace, 97 | } 98 | 99 | /// Separately represents methods which CANNOT take a body (GET, HEAD, OPTIONS, TRACE) 100 | /// and those which MAY take a body (POST, PATCH, PUT, DELETE) 101 | #[derive(Debug, Clone)] 102 | enum Method { 103 | WithoutBody(MethodWithoutBody), 104 | WithBody { 105 | method: MethodWithBody, 106 | /// The expected body payload, if any 107 | body_type: Option, 108 | }, 109 | } 110 | 111 | impl fmt::Display for Method { 112 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 113 | match self { 114 | Self::WithoutBody(method) => method.fmt(f), 115 | Self::WithBody { method, .. } => method.fmt(f), 116 | } 117 | } 118 | } 119 | 120 | impl Method { 121 | fn from_raw(method: RawMethod, body_type: Option) -> Result { 122 | use Method as M; 123 | use MethodWithBody::*; 124 | use MethodWithoutBody::*; 125 | use RawMethod as R; 126 | match method { 127 | R::Get | R::Head | R::Options | R::Trace => { 128 | if body_type.is_some() { 129 | invalid!("Method '{}' canoot have a body", method); 130 | } 131 | } 132 | _ => {} 133 | } 134 | let meth = match method { 135 | R::Get => M::WithoutBody(Get), 136 | R::Head => M::WithoutBody(Head), 137 | R::Trace => M::WithoutBody(Trace), 138 | R::Options => M::WithoutBody(Options), 139 | R::Post => M::WithBody { 140 | method: Post, 141 | body_type, 142 | }, 143 | R::Patch => M::WithBody { 144 | method: Patch, 145 | body_type, 146 | }, 147 | R::Put => M::WithBody { 148 | method: Put, 149 | body_type, 150 | }, 151 | R::Delete => M::WithBody { 152 | method: Delete, 153 | body_type, 154 | }, 155 | }; 156 | Ok(meth) 157 | } 158 | 159 | fn body_type(&self) -> Option<&TypePath> { 160 | match self { 161 | Method::WithoutBody(_) 162 | | Method::WithBody { 163 | body_type: None, .. 164 | } => None, 165 | Method::WithBody { 166 | body_type: Some(ref body_ty), 167 | .. 168 | } => Some(body_ty), 169 | } 170 | } 171 | } 172 | 173 | #[derive(Debug, Clone, Copy)] 174 | enum MethodWithoutBody { 175 | Get, 176 | Head, 177 | Options, 178 | Trace, 179 | } 180 | 181 | impl fmt::Display for MethodWithoutBody { 182 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 183 | use MethodWithoutBody::*; 184 | match self { 185 | Get => write!(f, "GET"), 186 | Head => write!(f, "HEAD"), 187 | Options => write!(f, "OPTIONS"), 188 | Trace => write!(f, "TRACE"), 189 | } 190 | } 191 | } 192 | 193 | impl fmt::Display for MethodWithBody { 194 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 195 | use MethodWithBody::*; 196 | match self { 197 | Post => write!(f, "POST"), 198 | Delete => write!(f, "DELETE"), 199 | Put => write!(f, "PUT"), 200 | Patch => write!(f, "PATCH"), 201 | } 202 | } 203 | } 204 | 205 | #[derive(Debug, Clone, Copy)] 206 | enum MethodWithBody { 207 | Post, 208 | Delete, 209 | Put, 210 | Patch, 211 | } 212 | 213 | /// A string which is a valid identifier (snake_case) 214 | /// 215 | /// Do not construct directly, instead use str.parse 216 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Display, Deref)] 217 | struct Ident(String); 218 | 219 | impl FromStr for Ident { 220 | type Err = Error; 221 | fn from_str(val: &str) -> Result { 222 | // Check the string is a valid identifier 223 | // We do not enforce any particular case 224 | let ident_re = Regex::new("^([[:alpha:]]|_)([[:alnum:]]|_)*$").unwrap(); 225 | if ident_re.is_match(val) { 226 | Ok(Ident(val.to_string())) 227 | } else { 228 | invalid!("Bad identifier '{}' (not a valid Rust identifier)", val) 229 | } 230 | } 231 | } 232 | 233 | impl quote::ToTokens for Ident { 234 | fn to_tokens(&self, tokens: &mut TokenStream) { 235 | let id = ident(&self.0); 236 | id.to_tokens(tokens) 237 | } 238 | } 239 | 240 | /// An ApiPath represents a nested location within the OpenAPI object. 241 | /// It can be used to keep track of where resources (particularly type 242 | /// definitions) are located. 243 | #[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)] 244 | pub(crate) struct ApiPath { 245 | path: Vec, 246 | } 247 | 248 | impl ApiPath { 249 | fn push(mut self, s: impl Into) -> Self { 250 | self.path.push(s.into()); 251 | self 252 | } 253 | } 254 | 255 | impl From for ApiPath { 256 | fn from(path: TypePath) -> Self { 257 | Self { path: path.0 } 258 | } 259 | } 260 | 261 | impl std::fmt::Display for ApiPath { 262 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> fmt::Result { 263 | let joined = self.path.join("."); 264 | write!(f, "{}", joined) 265 | } 266 | } 267 | 268 | /// A TypePath is a 'frozen' ApiPath, that points to the location 269 | /// where a type was defined. It's main use is as an identifier to use 270 | /// with the TypeLookup map, and to generate a canonical name for a type. 271 | /// Once created, it is intended to be read-only 272 | #[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)] 273 | pub(crate) struct TypePath(Vec); 274 | 275 | impl From for TypePath { 276 | fn from(path: ApiPath) -> Self { 277 | Self(path.path) 278 | } 279 | } 280 | 281 | impl TypePath { 282 | pub(crate) fn from_reference(refr: &str) -> Result { 283 | let rx = Regex::new("^#/components/schemas/([[:alnum:]]+)$").unwrap(); 284 | let cap = rx 285 | .captures(refr) 286 | .ok_or_else(|| Error::BadReference(refr.into()))?; 287 | let name = cap.get(1).unwrap(); 288 | let path = vec![ 289 | "components".into(), 290 | "schemas".into(), 291 | name.as_str().to_string(), 292 | ]; 293 | Ok(Self(path)) 294 | } 295 | 296 | // Turn an TypePath into a TypeName, which generally 297 | // will be the name actually used for a type definition 298 | pub(crate) fn canonicalize(&self) -> TypeName { 299 | let parts: Vec<&str> = self.0.iter().map(String::as_str).collect(); 300 | let parts = match &parts[..] { 301 | // if it is from 'components', strip out not-useful components path 302 | ["components", "schemas", rest @ ..] => &rest, 303 | // otherwise, assume it is from 'paths'. Ignore the first two sections 304 | // and start from 'operation id'. It is OK to do this because 305 | // operation ids are guaranteed to be unique 306 | ["paths", _path, _method, rest @ ..] => &rest, 307 | // else just take what we're given 308 | rest => rest, 309 | }; 310 | let joined = parts.join(" "); 311 | TypeName::from_str(&joined.to_pascal_case()).unwrap() 312 | } 313 | } 314 | 315 | /// A string which is a valid name for type (ClassCase) 316 | /// 317 | /// Do not construct directly, instead use `new` 318 | #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Display, Deref)] 319 | struct TypeName(String); 320 | 321 | impl FromStr for TypeName { 322 | type Err = Error; 323 | fn from_str(val: &str) -> Result { 324 | let camel = val.to_pascal_case(); 325 | if val == camel { 326 | Ok(TypeName(camel)) 327 | } else { 328 | invalid!("Bad type name '{}', must be ClassCase", val) 329 | } 330 | } 331 | } 332 | 333 | impl quote::ToTokens for TypeName { 334 | fn to_tokens(&self, tokens: &mut TokenStream) { 335 | let id = ident(&self.0); 336 | id.to_tokens(tokens) 337 | } 338 | } 339 | 340 | #[derive(Debug, Clone, PartialEq, Eq)] 341 | enum PathSegment { 342 | Literal(String), 343 | Parameter(String), 344 | } 345 | 346 | #[derive(Clone, Debug, PartialEq, Eq)] 347 | pub(crate) struct RoutePath { 348 | segments: Vec, 349 | } 350 | 351 | impl fmt::Display for RoutePath { 352 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 353 | let mut path = String::new(); 354 | for segment in &self.segments { 355 | match segment { 356 | PathSegment::Literal(p) => { 357 | path.push('/'); 358 | path.push_str(&p); 359 | } 360 | PathSegment::Parameter(p) => { 361 | path.push_str(&format!("/{{{}}}", p)); 362 | } 363 | } 364 | } 365 | write!(f, "{}", path) 366 | } 367 | } 368 | 369 | impl RoutePath { 370 | /// Check a path is well-formed and break it into its respective `PathSegment`s 371 | fn analyse(path: &str) -> Result { 372 | // "An alpha optionally followed by any of (alpha, number or _)" 373 | let literal_re = Regex::new("^[[:alpha:]]([[:alnum:]]|_)*$").unwrap(); 374 | let param_re = Regex::new(r#"^\{([[:alpha:]]([[:alnum:]]|_)*)\}$"#).unwrap(); 375 | 376 | if !path.starts_with('/') { 377 | invalid!("Bad path '{}' (must start with '/')", path); 378 | } 379 | 380 | let mut segments = Vec::new(); 381 | 382 | let mut dupe_params = Set::new(); 383 | for segment in path.split('/').skip(1) { 384 | if literal_re.is_match(segment) { 385 | segments.push(PathSegment::Literal(segment.to_string())) 386 | } else if let Some(seg) = param_re.captures(segment) { 387 | let param = seg.get(1).unwrap().as_str().to_string(); 388 | if !dupe_params.insert(param.clone()) { 389 | invalid!("Duplicate parameter in path '{}'", path); 390 | } 391 | segments.push(PathSegment::Parameter(param)) 392 | } else { 393 | invalid!("Bad path '{}'", path); 394 | } 395 | } 396 | Ok(RoutePath { segments }) 397 | } 398 | 399 | fn path_args(&self) -> impl Iterator { 400 | self.segments.iter().filter_map(|s| { 401 | if let PathSegment::Parameter(ref p) = s { 402 | Some(p.as_ref()) 403 | } else { 404 | None 405 | } 406 | }) 407 | } 408 | } 409 | 410 | #[derive(Debug, Copy, Clone, PartialEq, Eq)] 411 | pub(crate) enum Visibility { 412 | Public, 413 | Private, 414 | } 415 | 416 | impl quote::ToTokens for Visibility { 417 | fn to_tokens(&self, tokens: &mut TokenStream) { 418 | let tok = match self { 419 | Visibility::Public => quote! {pub}, 420 | Visibility::Private => quote! {}, 421 | }; 422 | tok.to_tokens(tokens) 423 | } 424 | } 425 | 426 | impl Default for Visibility { 427 | fn default() -> Self { 428 | Visibility::Public 429 | } 430 | } 431 | 432 | #[derive(Debug, Clone, PartialEq, Eq, Default)] 433 | pub(crate) struct TypeMetadata { 434 | title: Option, 435 | description: Option, 436 | nullable: bool, 437 | visibility: Visibility, 438 | } 439 | 440 | impl TypeMetadata { 441 | fn with_visibility(self, visibility: Visibility) -> Self { 442 | Self { visibility, ..self } 443 | } 444 | 445 | fn with_description(self, description: String) -> Self { 446 | Self { 447 | description: Some(description), 448 | ..self 449 | } 450 | } 451 | 452 | fn description(&self) -> Option { 453 | self.description.as_ref().map(|s| { 454 | quote! { 455 | #[doc = #s] 456 | } 457 | }) 458 | } 459 | } 460 | 461 | impl From for TypeMetadata { 462 | fn from(from: openapiv3::SchemaData) -> Self { 463 | Self { 464 | title: from.title, 465 | description: from.description, 466 | nullable: from.nullable, 467 | visibility: Visibility::Public, 468 | } 469 | } 470 | } 471 | 472 | #[derive(Debug, Clone, PartialEq, Eq, Default)] 473 | pub(crate) struct FieldMetadata { 474 | description: Option, 475 | required: bool, 476 | } 477 | 478 | impl FieldMetadata { 479 | fn with_required(self, required: bool) -> Self { 480 | Self { required, ..self } 481 | } 482 | } 483 | 484 | pub(crate) fn variant_from_status_code(code: &StatusCode) -> Ident { 485 | code.canonical_reason() 486 | .and_then(|reason| reason.to_pascal_case().parse().ok()) 487 | .unwrap_or_else(|| format!("Status{}", code.as_str()).parse().unwrap()) 488 | } 489 | 490 | fn doc_comment(msg: impl AsRef) -> TokenStream { 491 | let msg = msg.as_ref(); 492 | quote! { 493 | #[doc = #msg] 494 | } 495 | } 496 | 497 | fn get_derive_tokens() -> TokenStream { 498 | quote! { 499 | # [derive(Debug, Clone, PartialEq, hsr::Serialize, hsr::Deserialize)] 500 | } 501 | } 502 | 503 | fn generate_rust_interface( 504 | routes: &Map>, 505 | title: &str, 506 | trait_name: &TypeName, 507 | ) -> TokenStream { 508 | let mut methods = TokenStream::new(); 509 | let descr = doc_comment(format!("Api generated from '{}' spec", title)); 510 | for (_, route_methods) in routes { 511 | for route in route_methods { 512 | methods.extend(route.generate_api_signature()); 513 | } 514 | } 515 | quote! { 516 | #descr 517 | #[hsr::async_trait::async_trait(?Send)] 518 | pub trait #trait_name: 'static + Send + Sync { 519 | #methods 520 | } 521 | } 522 | } 523 | 524 | fn generate_rust_dispatchers( 525 | routes: &Map>, 526 | trait_name: &TypeName, 527 | ) -> TokenStream { 528 | let mut dispatchers = TokenStream::new(); 529 | for (_api_path, route_methods) in routes { 530 | for route in route_methods { 531 | dispatchers.extend(route.generate_dispatcher(trait_name)); 532 | } 533 | } 534 | quote! {#dispatchers} 535 | } 536 | 537 | fn generate_rust_server(routemap: &Map>, trait_name: &TypeName) -> TokenStream { 538 | let resources: Vec<_> = routemap 539 | .iter() 540 | .map(|(path, routes)| { 541 | let (meth, opid): (Vec<_>, Vec<_>) = routes 542 | .iter() 543 | .map(|route| { 544 | ( 545 | ident(route.method().to_string().to_snake_case()), 546 | route.operation_id(), 547 | ) 548 | }) 549 | .unzip(); 550 | quote! { 551 | web::resource(#path) 552 | #(.route(web::#meth().to(#opid::)))* 553 | } 554 | }) 555 | .collect(); 556 | 557 | let server = quote! { 558 | #[allow(dead_code)] 559 | pub mod server { 560 | use super::*; 561 | 562 | fn configure_hsr(cfg: &mut actix_web::web::ServiceConfig) { 563 | cfg #(.service(#resources))*; 564 | } 565 | 566 | /// Serve the API on a given host. 567 | /// Once started, the server blocks indefinitely. 568 | pub async fn serve(api: A, cfg: hsr::Config) -> std::io::Result<()> { 569 | // We register the user-supplied Api as a Data item. 570 | // You might think it would be cleaner to generate out API trait 571 | // to not take "self" at all (only inherent impls) and then just 572 | // have Actix call those functions directly, like `.to(Api::func)`. 573 | // However we also want a way for the user to pass in arbitrary state to 574 | // handlers, so we kill two birds with one stone by stashing the Api 575 | // as data, pulling then it back out upon each request and calling 576 | // the handler as a method 577 | let api = AxData::new(api); 578 | 579 | let server = HttpServer::new(move || { 580 | App::new() 581 | .app_data(api.clone()) 582 | .wrap(Logger::default()) 583 | .configure(|cfg| hsr::configure_spec(cfg, JSON_SPEC, UI_TEMPLATE)) 584 | .configure(configure_hsr::) 585 | }); 586 | 587 | // Bind to socket 588 | let server = if let Some(ssl) = cfg.ssl { 589 | server.bind_openssl((cfg.host.host_str().unwrap(), cfg.host.port().unwrap()), ssl) 590 | } else { 591 | server.bind((cfg.host.host_str().unwrap(), cfg.host.port().unwrap())) 592 | }?; 593 | 594 | // run! 595 | server.run().await 596 | } 597 | } 598 | }; 599 | server 600 | } 601 | 602 | fn generate_rust_client(routes: &Map>) -> TokenStream { 603 | let mut method_impls = TokenStream::new(); 604 | for (_, route_methods) in routes { 605 | for route in route_methods { 606 | method_impls.extend(route.generate_client_impl()); 607 | } 608 | } 609 | 610 | quote! { 611 | #[allow(dead_code)] 612 | #[allow(unused_imports)] 613 | pub mod client { 614 | use super::*; 615 | use hsr::actix_http::Method; 616 | use hsr::awc::Client as ActixClient; 617 | use hsr::ClientError; 618 | use hsr::futures::future::{err as fut_err, ok as fut_ok}; 619 | use hsr::serde_urlencoded; 620 | 621 | pub struct Client { 622 | domain: Url, 623 | inner: ActixClient, 624 | } 625 | 626 | impl Client { 627 | 628 | pub fn new(domain: Url) -> Self { 629 | Client { 630 | domain: domain, 631 | inner: ActixClient::new() 632 | } 633 | } 634 | 635 | #method_impls 636 | } 637 | } 638 | } 639 | } 640 | 641 | pub fn generate_from_yaml_file(yaml: impl AsRef) -> Result { 642 | // TODO add generate_from_json_file 643 | let f = fs::File::open(yaml)?; 644 | generate_from_yaml_source(f) 645 | } 646 | 647 | pub fn generate_from_yaml_source(mut yaml: impl std::io::Read) -> Result { 648 | // Read the yaml file into an OpenAPI struct 649 | let mut openapi_source = String::new(); 650 | yaml.read_to_string(&mut openapi_source)?; 651 | let api: OpenAPI = serde_yaml::from_str(&openapi_source)?; 652 | 653 | // pull out various sections of the OpenAPI object which will be useful 654 | // let components = api.components.take().unwrap_or_default(); 655 | // let schema_lookup = components.schemas; 656 | // let response_lookup = components.responses; 657 | // let parameters_lookup = components.parameters; 658 | // let req_body_lookup = components.request_bodies; 659 | 660 | // Generate the spec as json. This will be embedded in the binary 661 | let json_spec = serde_json::to_string(&api).expect("Bad api serialization"); 662 | 663 | let trait_name = api_trait_name(&api); 664 | 665 | // Walk the API to collect types and routes 666 | debug!("Gather types"); 667 | let (type_lookup, routes) = walk::walk_api(&api)?; 668 | 669 | // Generate type definitions 670 | debug!("Generate API types"); 671 | let rust_api_types = walk::generate_rust_types(&type_lookup)?; 672 | 673 | // Response types are slightly special cases (they need to implement Responder 674 | debug!("Generate response types"); 675 | let rust_response_types: Vec<_> = routes 676 | .values() 677 | .map(|routes| routes.iter().map(|route| route.generate_return_type())) 678 | .flatten() 679 | .collect(); 680 | 681 | debug!("Generate API trait"); 682 | let rust_trait = generate_rust_interface(&routes, &api.info.title, &trait_name); 683 | 684 | debug!("Generate dispatchers"); 685 | let rust_dispatchers = generate_rust_dispatchers(&routes, &trait_name); 686 | 687 | debug!("Generate server"); 688 | let rust_server = generate_rust_server(&routes, &trait_name); 689 | 690 | debug!("Generate client"); 691 | let rust_client = generate_rust_client(&routes); 692 | 693 | let code = quote! { 694 | #[allow(dead_code)] 695 | 696 | // Dump the spec and the ui template in the source file, for serving ui 697 | const JSON_SPEC: &'static str = #json_spec; 698 | const UI_TEMPLATE: &'static str = #SWAGGER_UI_TEMPLATE; 699 | 700 | mod __imports { 701 | pub use hsr::HasStatusCode; 702 | pub use hsr::actix_web::{ 703 | self, App, HttpServer, HttpRequest, HttpResponse, Responder, Either as AxEither, 704 | Error as ActixError, 705 | error::ErrorInternalServerError, 706 | web::{self, Json as AxJson, Query as AxQuery, Path as AxPath, Data as AxData, ServiceConfig}, 707 | body::BoxBody, 708 | HttpResponseBuilder, 709 | middleware::Logger 710 | }; 711 | pub use hsr::url::Url; 712 | pub use hsr::actix_http::{StatusCode}; 713 | pub use hsr::futures::future::{Future, FutureExt, TryFutureExt, Ready, ok as fut_ok}; 714 | pub use hsr::serde_json::Value as JsonValue; 715 | 716 | // macros re-exported from `serde-derive` 717 | pub use hsr::{Serialize, Deserialize}; 718 | } 719 | #[allow(dead_code)] 720 | use __imports::*; 721 | 722 | // Type definitions 723 | #rust_api_types 724 | #(#rust_response_types)* 725 | // Interface definition 726 | #rust_trait 727 | // Dispatcher definitions 728 | #rust_dispatchers 729 | // Server 730 | #rust_server 731 | // Client 732 | #rust_client 733 | }; 734 | let code = code.to_string(); 735 | #[cfg(feature = "pretty")] 736 | { 737 | debug!("Prettify"); 738 | prettify_code(code) 739 | } 740 | #[cfg(not(feature = "pretty"))] 741 | { 742 | Ok(code) 743 | } 744 | } 745 | 746 | /// Run the code through `rustfmt`. 747 | #[cfg(feature = "pretty")] 748 | pub fn prettify_code(input: String) -> Result { 749 | let formatted: String = rustfmt_wrapper::rustfmt(input).unwrap(); 750 | Ok(formatted) 751 | } 752 | 753 | #[cfg(test)] 754 | mod tests { 755 | use super::*; 756 | 757 | #[test] 758 | fn test_snake_casify() { 759 | assert_eq!("/a/b/c".to_snake_case(), "a_b_c"); 760 | assert_eq!( 761 | "/All/ThisIs/justFine".to_snake_case(), 762 | "all_this_is_just_fine" 763 | ); 764 | assert_eq!("/{someId}".to_snake_case(), "some_id"); 765 | assert_eq!( 766 | "/123_abc{xyz\\!\"£$%^}/456 asdf".to_snake_case(), 767 | "123_abc_xyz_456_asdf" 768 | ) 769 | } 770 | 771 | #[test] 772 | fn test_valid_identifier() { 773 | assert!(Ident::from_str("x").is_ok()); 774 | assert!(Ident::from_str("_").is_ok()); 775 | assert!(Ident::from_str("x1").is_ok()); 776 | assert!(Ident::from_str("x1_23_aB").is_ok()); 777 | 778 | assert!(Ident::from_str("").is_err()); 779 | assert!(Ident::from_str("1abc").is_err()); 780 | assert!(Ident::from_str("abc!").is_err()); 781 | } 782 | 783 | #[test] 784 | fn test_analyse_path() { 785 | use PathSegment::*; 786 | 787 | // Should fail 788 | assert!(RoutePath::analyse("").is_err()); 789 | assert!(RoutePath::analyse("a").is_err()); 790 | assert!(RoutePath::analyse("/a/").is_err()); 791 | assert!(RoutePath::analyse("/a/b/c/").is_err()); 792 | assert!(RoutePath::analyse("/a{").is_err()); 793 | assert!(RoutePath::analyse("/a{}").is_err()); 794 | assert!(RoutePath::analyse("/{}a").is_err()); 795 | assert!(RoutePath::analyse("/{a}a").is_err()); 796 | assert!(RoutePath::analyse("/ a").is_err()); 797 | assert!(RoutePath::analyse("/1").is_err()); 798 | assert!(RoutePath::analyse("/a//b").is_err()); 799 | 800 | assert!(RoutePath::analyse("/a").is_ok()); 801 | assert!(RoutePath::analyse("/a/b/c").is_ok()); 802 | assert!(RoutePath::analyse("/a/a/a").is_ok()); 803 | assert!(RoutePath::analyse("/a1/b2/c3").is_ok()); 804 | 805 | assert!(RoutePath::analyse("/{a1}").is_ok()); 806 | assert!(RoutePath::analyse("/{a1}/b2/{c3}").is_ok()); 807 | assert!(RoutePath::analyse("/{a1B2c3}").is_ok()); 808 | assert!(RoutePath::analyse("/{a1_b2_c3}").is_ok()); 809 | 810 | // duplicate param 811 | assert!(RoutePath::analyse("/{a}/{b}/{a}").is_err()); 812 | 813 | assert_eq!( 814 | RoutePath::analyse("/{a_1}/{b2C3}/a/b").unwrap(), 815 | RoutePath { 816 | segments: vec![ 817 | Parameter("a_1".into()), 818 | Parameter("b2C3".into()), 819 | Literal("a".into()), 820 | Literal("b".into()) 821 | ] 822 | } 823 | ); 824 | } 825 | 826 | // #[test] 827 | // fn test_build_types_complex() { 828 | // let yaml = "example-api/petstore-expanded.yaml"; 829 | // let yaml = fs::read_to_string(yaml).unwrap(); 830 | // let api: OpenAPI = serde_yaml::from_str(&yaml).unwrap(); 831 | // gather_types(&api).unwrap(); 832 | // } 833 | } 834 | -------------------------------------------------------------------------------- /hsr-codegen/src/route.rs: -------------------------------------------------------------------------------- 1 | use actix_http::StatusCode; 2 | use heck::ToSnakeCase; 3 | use openapiv3::{ReferenceOr, StatusCode as ApiStatusCode}; 4 | use proc_macro2::TokenStream; 5 | use quote::quote; 6 | 7 | use std::collections::HashMap; 8 | use std::convert::TryFrom; 9 | use std::hash::Hash; 10 | use std::ops::Deref; 11 | 12 | use crate::walk::{generate_enum_def, Type, Variant}; 13 | use crate::*; 14 | 15 | // Just the bits of the Responses that the Route needs to know about 16 | #[derive(Debug, Clone)] 17 | pub(crate) struct Responses { 18 | pub with_codes: Map, 19 | pub default: Option, 20 | } 21 | 22 | #[derive(Debug, Clone)] 23 | pub(crate) struct Response { 24 | pub description: String, 25 | pub type_path: Option, 26 | } 27 | 28 | /// Route contains all the information necessary to contruct the API 29 | /// 30 | /// If it has been constructed, the route is logically sound 31 | #[derive(Debug, Clone, derive_more::Constructor)] 32 | pub(crate) struct Route { 33 | summary: Option, 34 | description: Option, 35 | operation_id: Ident, 36 | method: Method, 37 | path: RoutePath, 38 | path_params: Option<(TypePath, Map)>, 39 | query_params: Option<(TypePath, Map)>, 40 | responses: Responses, 41 | } 42 | 43 | impl Route { 44 | pub(crate) fn method(&self) -> &Method { 45 | &self.method 46 | } 47 | 48 | pub(crate) fn operation_id(&self) -> &Ident { 49 | &self.operation_id 50 | } 51 | 52 | fn return_ty_name(&self) -> TypeName { 53 | TypeName::from_str(&self.operation_id.deref().to_pascal_case()).unwrap() 54 | } 55 | 56 | fn documentation(&self) -> TokenStream { 57 | let summary = self.summary.as_ref().map(doc_comment); 58 | let descr = self.description.as_ref().map(doc_comment); 59 | quote! { 60 | #summary 61 | #descr 62 | } 63 | } 64 | 65 | /// The name of the return type. If none are found, returns '()'. 66 | /// If both Success and Error types exist, will be a Result type 67 | pub(crate) fn generate_return_type(&self) -> TokenStream { 68 | let enum_name = self.return_ty_name(); 69 | let variants: Vec<_> = self 70 | .responses 71 | .with_codes 72 | .iter() 73 | .map(|(code, resp)| { 74 | Variant::new(variant_from_status_code(code)) 75 | .description(resp.description.clone()) 76 | .type_path(resp.type_path.clone()) 77 | }) 78 | .collect(); 79 | let default_variant = self.responses.default.as_ref().map(|dflt| { 80 | Variant::new("Default".parse().unwrap()) 81 | .description(dflt.description.clone()) 82 | .type_path(dflt.type_path.clone()) 83 | }); 84 | let meta = TypeMetadata::default() 85 | .with_description(format!("Returned from operation '{}'", self.operation_id)); 86 | let enum_def = generate_enum_def( 87 | &enum_name, 88 | &meta, 89 | &variants, 90 | default_variant.as_ref(), 91 | false, 92 | ); 93 | 94 | let status_matches = { 95 | let mut status_matches: Vec<_> = self 96 | .responses 97 | .with_codes 98 | .iter() 99 | .map(|(code, response)| { 100 | let var_name = variant_from_status_code(code); 101 | let code_lit = proc_macro2::Literal::u16_unsuffixed(code.as_u16()); 102 | match response.type_path { 103 | Some(_) => quote! { 104 | #var_name(_) => StatusCode::from_u16(#code_lit).unwrap() 105 | }, 106 | None => quote! { 107 | #var_name => StatusCode::from_u16(#code_lit).unwrap() 108 | }, 109 | } 110 | }) 111 | .collect(); 112 | if let Some(_) = self.responses.default.as_ref() { 113 | status_matches.push( 114 | // TODO print warning on bad code 115 | quote! { 116 | Default { status_code, .. } => { 117 | StatusCode::from_u16(*status_code) 118 | .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) 119 | } 120 | }, 121 | ) 122 | } 123 | status_matches 124 | }; 125 | 126 | let response_match_arms = { 127 | let mut response_match_arms: Vec<_> = variants 128 | .iter() 129 | .map( 130 | |Variant { 131 | name, type_path, .. 132 | }| match type_path { 133 | Some(_) => { 134 | quote! { 135 | #name(inner) => { 136 | HttpResponse::build(status_code).json(inner) 137 | } 138 | } 139 | } 140 | None => { 141 | quote! { 142 | #name => { 143 | HttpResponse::build(status_code).finish() 144 | } 145 | } 146 | } 147 | }, 148 | ) 149 | .collect(); 150 | if let Some(dflt) = &self.responses.default { 151 | match dflt.type_path { 152 | None => response_match_arms.push(quote! { 153 | Default { .. } => HttpResponseBuilder::new(status_code).finish() 154 | }), 155 | Some(_) => response_match_arms.push(quote! { 156 | Default { body, .. } => HttpResponseBuilder::new(status_code).json(body) 157 | }), 158 | } 159 | } 160 | response_match_arms 161 | }; 162 | 163 | quote! { 164 | 165 | #enum_def 166 | 167 | impl HasStatusCode for #enum_name { 168 | fn status_code(&self) -> StatusCode { 169 | use #enum_name::*; 170 | match self { 171 | #(#status_matches,)* 172 | } 173 | } 174 | } 175 | 176 | impl Responder for #enum_name { 177 | type Body = BoxBody; 178 | fn respond_to(self, _req: &HttpRequest) -> HttpResponse { 179 | use #enum_name::*; 180 | let status_code = self.status_code(); 181 | let resp = match self { 182 | #(#response_match_arms)* 183 | }; 184 | resp 185 | } 186 | } 187 | 188 | } 189 | } 190 | 191 | /// Generate the function signature compatible with the Route 192 | pub(crate) fn generate_api_signature(&self) -> TokenStream { 193 | let opid = &self.operation_id; 194 | let api_return_ty = self.return_ty_name(); 195 | 196 | let paths: Vec<_> = self 197 | .path_params 198 | .as_ref() 199 | .map(|(_, params)| { 200 | params 201 | .iter() 202 | .map(|(id, (meta, ty))| { 203 | assert!(meta.required, "path params are always required"); 204 | let type_name = ty.canonicalize(); 205 | quote! { 206 | #id: #type_name 207 | } 208 | }) 209 | .collect() 210 | }) 211 | .unwrap_or(Vec::new()); 212 | 213 | let queries: Vec<_> = self 214 | .query_params 215 | .as_ref() 216 | .map(|(_, params)| { 217 | params 218 | .iter() 219 | .map(|(id, (meta, ty))| { 220 | let type_name = ty.canonicalize(); 221 | if meta.required { 222 | quote! { 223 | #id: #type_name 224 | } 225 | } else { 226 | quote! { 227 | #id: Option<#type_name> 228 | } 229 | } 230 | }) 231 | .collect() 232 | }) 233 | .unwrap_or(Vec::new()); 234 | 235 | let body_arg_opt = self.method.body_type().map(|body_ty| { 236 | let body_ty = body_ty.canonicalize(); 237 | let name = ident("payload"); 238 | Some(quote! { #name: #body_ty, }) 239 | }); 240 | let docs = self.documentation(); 241 | // define the trait method which the user must implement 242 | quote! { 243 | #docs 244 | async fn #opid(&self, #(#paths,)* #(#queries,)* #body_arg_opt) -> #api_return_ty; 245 | } 246 | } 247 | 248 | /// Generate the client implementation. 249 | /// 250 | /// It takes a bit of care to build up this code. Unfortunately we can't just implement 251 | /// the API trait because we have to be able to return connection errors etc 252 | /// Which requires a `Result` type. 253 | pub(crate) fn generate_client_impl(&self) -> TokenStream { 254 | let opid = &self.operation_id; 255 | let result_type = self.return_ty_name(); 256 | 257 | // build useful path and query iterators 258 | let (path_names, path_types) = self 259 | .path_params 260 | .as_ref() 261 | .map(|(_, params)| { 262 | params 263 | .iter() 264 | .map(|(id, (_meta, ty))| (id, ty.canonicalize())) 265 | .unzip() 266 | }) 267 | .unwrap_or((Vec::new(), Vec::new())); 268 | 269 | let query_name_type_pairs = self 270 | .query_params 271 | .as_ref() 272 | .map(|(_, params)| { 273 | params 274 | .iter() 275 | .map(|(id, (meta, ty))| { 276 | let type_name = ty.canonicalize(); 277 | if meta.required { 278 | quote! { 279 | #id: #type_name 280 | } 281 | } else { 282 | quote! { 283 | #id: Option<#type_name> 284 | } 285 | } 286 | }) 287 | .collect() 288 | }) 289 | .unwrap_or(Vec::new()); 290 | 291 | // template the code to add query parameters to the url, if necessary 292 | let add_query_string_to_url = self.query_params.as_ref().map(|(type_path, params)| { 293 | let type_name = type_path.canonicalize(); 294 | let fields = params.iter().map(|(id, _)| id); 295 | quote! { 296 | { 297 | // construct and instance of our query param type 298 | // then url-encode into the string 299 | let qstyp = #type_name { 300 | #(#fields,)* 301 | }; 302 | let qs = serde_urlencoded::to_string(qstyp).unwrap(); 303 | url.set_query(Some(&qs)); 304 | } 305 | } 306 | }); 307 | 308 | // if there is a payload in the body, make sure to add it (as json) 309 | let (body_arg_opt, send_request) = match self.method.body_type() { 310 | None => (None, quote! {.send()}), 311 | Some(ref body_type_path) => { 312 | let body_name = body_type_path.canonicalize(); 313 | ( 314 | Some(quote! { payload: #body_name, }), 315 | quote! { .send_json(&payload) }, 316 | ) 317 | } 318 | }; 319 | 320 | let method = ident(&self.method); 321 | let path_template = self.path.to_string(); 322 | 323 | // We will need to deserialize the response based on the status code 324 | // Build up the match arms that will do so 325 | let resp_match_arms = { 326 | let mut resp_match_arms: Vec<_> = self 327 | .responses 328 | .with_codes 329 | .iter() 330 | .map(|(code, response)| { 331 | let status_code_literal = proc_macro2::Literal::u16_unsuffixed(code.as_u16()); 332 | let variant = variant_from_status_code(code); 333 | match &response.type_path { 334 | Some(type_path) => { 335 | // there is a payload associated with the response type 336 | // so attempt to deserialize it 337 | let type_name = type_path.canonicalize(); 338 | quote! { 339 | #status_code_literal => { 340 | match resp 341 | .json::<#type_name>() 342 | .await { 343 | Ok(body) => Result::Ok(#result_type::#variant(body)), 344 | Err(e) => Result::Err(ClientError::Actix(ErrorInternalServerError(e))) 345 | } 346 | } 347 | } 348 | } 349 | None => { 350 | // There is no payload with this response, just return the bare variant 351 | // TODO: Check the payload is empty? 352 | quote! { 353 | #status_code_literal => { 354 | // could check body is empty here? 355 | Result::Ok(#result_type::#variant) 356 | } 357 | } 358 | } 359 | } 360 | }) 361 | .collect(); 362 | 363 | // we have done the 'expected' matches. Now, what if we get an unknown status code? 364 | // Depends on whether we have a 'default' response 365 | let fallthough_match = match &self.responses.default { 366 | None => quote! { 367 | _ => Result::Err(ClientError::BadStatus(resp.status())) 368 | }, 369 | Some(dflt) => match &dflt.type_path { 370 | None => quote! { 371 | status_code => Result::Ok(#result_type::Default { status_code }) 372 | }, 373 | Some(type_path) => { 374 | let type_name = type_path.canonicalize(); 375 | quote! { 376 | status_code => { 377 | match resp 378 | .json::<#type_name>() 379 | .await { 380 | Ok(body) => Result::Ok(#result_type::Default { status_code, body }), 381 | Err(e) => Result::Err(ClientError::Actix(ErrorInternalServerError(e))) 382 | } 383 | } 384 | } 385 | } 386 | }, 387 | }; 388 | resp_match_arms.push(fallthough_match); 389 | resp_match_arms 390 | }; 391 | 392 | // Finally we can piece everything together 393 | quote! { 394 | #[allow(unused_mut)] 395 | pub async fn #opid( 396 | &self, 397 | #(#path_names: #path_types,)* 398 | #(#query_name_type_pairs,)* 399 | #body_arg_opt 400 | ) -> Result<#result_type, ClientError> 401 | { 402 | // Build up our request path 403 | let path = format!(#path_template, #(#path_names = #path_names,)*); 404 | let mut url = self.domain.join(&path).unwrap(); 405 | #add_query_string_to_url 406 | 407 | let mut resp = self.inner 408 | .request(Method::#method, url.as_str()) 409 | // Send, giving a future containing an HttpResponse 410 | #send_request 411 | .await.map_err(ErrorInternalServerError)?; 412 | // We match on the status type to handle the return correctly 413 | match resp.status().as_u16() { 414 | #(#resp_match_arms)* 415 | } 416 | } 417 | } 418 | } 419 | 420 | /// If there are multitple difference error types, construct an 421 | /// enum to hold them all. If there is only one or none, don't bother. 422 | /// Generate the dispatcher function. This function wraps the 423 | /// interface function in a shim that translates the signature into a form 424 | /// that Actix expects. 425 | /// 426 | /// Specifically, we generate a function that accepts Path, Query and Json types, 427 | /// extracts the values from these types, calls the API function with the values, 428 | /// and wraps the resulting Future3 type to return a Future1 with corresponding Ok 429 | /// and Error types. 430 | pub(crate) fn generate_dispatcher(&self, trait_name: &TypeName) -> TokenStream { 431 | // XXX this function is a total mess, there must be a better way to do it. 432 | // After all, it seems we have got the API signatures right/OK? 433 | let opid = &self.operation_id; 434 | 435 | // path args handling 436 | let path_param_fields = &self 437 | .path_params 438 | .as_ref() 439 | .map(|(_, params)| params.keys().collect::>()) 440 | .unwrap_or_default(); 441 | let (path_arg_opt, path_destructure_opt) = { 442 | self.path_params.as_ref().map(|(name, _params)| { 443 | let name = name.canonicalize(); 444 | let path_destructure = quote! { 445 | let #name { #(#path_param_fields),* } = path.into_inner(); 446 | }; 447 | let path_arg = quote! { 448 | path: AxPath<#name>, 449 | }; 450 | (Some(path_arg), Some(path_destructure)) 451 | }) 452 | } 453 | .unwrap_or((None, None)); 454 | 455 | // query args handling 456 | let query_param_fields = &self 457 | .query_params 458 | .as_ref() 459 | .map(|(_, params)| params.keys().collect::>()) 460 | .unwrap_or_default(); 461 | 462 | let (query_arg_opt, query_destructure_opt) = { 463 | self.query_params.as_ref().map(|(name, _params)| { 464 | let name = name.canonicalize(); 465 | let query_destructure = quote! { 466 | let #name { #(#query_param_fields),* } = query.into_inner(); 467 | }; 468 | let query_arg = quote! { 469 | query: AxQuery<#name>, 470 | }; 471 | (Some(query_arg), Some(query_destructure)) 472 | }) 473 | } 474 | .unwrap_or((None, None)); 475 | 476 | let (body_arg_opt, body_ident_opt) = self 477 | .method 478 | .body_type() 479 | .map(TypePath::canonicalize) 480 | .map(|body_ty| { 481 | ( 482 | Some(quote! { AxJson(body): AxJson<#body_ty>, }), 483 | Some(ident("body")), 484 | ) 485 | }) 486 | .unwrap_or((None, None)); 487 | 488 | let return_ty = self.return_ty_name(); 489 | 490 | let code = quote! { 491 | // define the 'top level' function which is called directly by actix 492 | async fn #opid( 493 | data: AxData, 494 | #path_arg_opt 495 | #query_arg_opt 496 | #body_arg_opt 497 | ) -> #return_ty { 498 | 499 | // destructure path and query parameters into variables, if any 500 | #path_destructure_opt 501 | #query_destructure_opt 502 | // call our API handler function with requisite arguments 503 | data.#opid( 504 | #(#path_param_fields,)* 505 | #(#query_param_fields,)* 506 | #body_ident_opt 507 | ).await 508 | } 509 | }; 510 | code 511 | } 512 | } 513 | 514 | #[derive(Debug, Clone, derive_more::Constructor, derive_more::Deref)] 515 | struct Counter(HashMap); 516 | 517 | impl std::iter::FromIterator for Counter { 518 | fn from_iter(iter: T) -> Self 519 | where 520 | T: IntoIterator, 521 | { 522 | let mut counter = Counter(HashMap::new()); 523 | for item in iter { 524 | let ct = counter.0.entry(item).or_insert(0); 525 | *ct += 1 526 | } 527 | counter 528 | } 529 | } 530 | 531 | impl Counter { 532 | fn find_duplicates(&self) -> Vec<&A> { 533 | self.0 534 | .iter() 535 | .filter_map(|(val, &ct)| if ct > 1 { Some(val) } else { None }) 536 | .collect() 537 | } 538 | } 539 | 540 | /// Validations which require checking across all routes 541 | pub(crate) fn validate_routes(routes: &Map>) -> Result<()> { 542 | let operation_id_cts: Counter<_> = routes 543 | .iter() 544 | .map(|(_, routes)| routes.iter()) 545 | .flatten() 546 | .map(|route| route.operation_id()) 547 | .collect(); 548 | let dupes = operation_id_cts.find_duplicates(); 549 | if !dupes.is_empty() { 550 | invalid!("Duplicate operationId: '{}'", dupes[0]) 551 | } else { 552 | Ok(()) 553 | } 554 | } 555 | -------------------------------------------------------------------------------- /hsr-codegen/src/walk.rs: -------------------------------------------------------------------------------- 1 | use heck::ToPascalCase; 2 | use indexmap::{IndexMap as Map, IndexSet as Set}; 3 | use log::debug; 4 | use openapiv3::{ 5 | AdditionalProperties, AnySchema, Components, ObjectType, OpenAPI, Operation, Parameter, 6 | ParameterSchemaOrContent, ReferenceOr, Schema, SchemaData, SchemaKind, 7 | StatusCode as ApiStatusCode, Type as ApiType, 8 | }; 9 | use proc_macro2::TokenStream; 10 | use quote::quote; 11 | use regex::Regex; 12 | 13 | use std::collections::BTreeMap; 14 | use std::convert::TryFrom; 15 | use std::fmt; 16 | use std::ops::Deref; 17 | 18 | use crate::{ 19 | dereference, doc_comment, get_derive_tokens, unwrap_ref, variant_from_status_code, ApiPath, 20 | Error, FieldMetadata, Ident, Method, MethodWithBody, MethodWithoutBody, RawMethod, Result, 21 | RoutePath, SchemaLookup, StatusCode, TypeMetadata, TypeName, TypePath, Visibility, 22 | }; 23 | 24 | use crate::route::{validate_routes, Response, Responses, Route}; 25 | 26 | use proc_macro2::Ident as QIdent; 27 | 28 | pub(crate) type TypeLookup = BTreeMap>; 29 | 30 | fn lookup_type_recursive<'a>( 31 | item: &'a ReferenceOr, 32 | lookup: &'a TypeLookup, 33 | ) -> Result<&'a Type> { 34 | match item { 35 | ReferenceOr::Reference { reference } => { 36 | let path = TypePath::from_reference(reference)?.into(); 37 | lookup 38 | .get(&path) 39 | .ok_or_else(|| Error::BadReference(reference.clone())) 40 | .and_then(|refr| lookup_type_recursive(refr, lookup)) 41 | } 42 | ReferenceOr::Item(typ) => Ok(typ), 43 | } 44 | } 45 | 46 | #[derive(Clone, PartialEq)] 47 | pub(crate) struct Type { 48 | meta: TypeMetadata, 49 | typ: TypeInner, 50 | } 51 | 52 | impl fmt::Debug for Type { 53 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 54 | write!(f, "Type {{ inner: {:?} }}", self.typ) 55 | } 56 | } 57 | 58 | #[derive(Debug, Clone, PartialEq, derive_more::Display)] 59 | enum Primitive { 60 | #[display(fmt = "String")] 61 | String, 62 | #[display(fmt = "f64")] 63 | F64, 64 | #[display(fmt = "i64")] 65 | I64, 66 | #[display(fmt = "bool")] 67 | Bool, 68 | } 69 | 70 | /// Represent a variant of an enum 71 | #[derive(Debug, Clone)] 72 | pub(crate) struct Variant { 73 | pub name: Ident, 74 | pub description: Option, 75 | pub type_path: Option, 76 | pub rename: Option, 77 | } 78 | 79 | impl Variant { 80 | pub fn new(name: Ident) -> Self { 81 | Self { 82 | name, 83 | description: None, 84 | type_path: None, 85 | rename: None, 86 | } 87 | } 88 | 89 | pub(crate) fn description(self, description: String) -> Self { 90 | Self { 91 | description: Some(description), 92 | ..self 93 | } 94 | } 95 | 96 | pub(crate) fn type_path(self, type_path: Option) -> Self { 97 | Self { type_path, ..self } 98 | } 99 | 100 | pub(crate) fn rename(self, rename: String) -> Self { 101 | Self { 102 | rename: Some(rename), 103 | ..self 104 | } 105 | } 106 | } 107 | 108 | impl quote::ToTokens for Variant { 109 | fn to_tokens(&self, tokens: &mut TokenStream) { 110 | let descr = self.description.as_ref().map(doc_comment); 111 | let name = &self.name; 112 | let rename = self.rename.as_ref().map(|name| { 113 | quote! { 114 | #[serde(rename = #name)] 115 | } 116 | }); 117 | let tok = match self.type_path.as_ref() { 118 | Some(path) => { 119 | let varty = path.canonicalize(); 120 | quote! { 121 | #descr 122 | #rename 123 | #name(#varty) 124 | } 125 | } 126 | None => { 127 | quote! { 128 | #descr 129 | #rename 130 | #name 131 | } 132 | } 133 | }; 134 | tokens.extend(tok); 135 | } 136 | } 137 | 138 | #[derive(Debug, Clone, PartialEq)] 139 | enum TypeInner { 140 | // primitives 141 | Primitive(Primitive), 142 | // String that can only take set values 143 | StringEnum(Vec), 144 | // An array of of some inner type 145 | Array(Box>), 146 | // Any type. Could be anything! Probably a user-error 147 | Any, 148 | AllOf(Vec>), 149 | OneOf(Vec), 150 | Struct(Struct), 151 | } 152 | 153 | impl TypeInner { 154 | /// Attach metadata 155 | fn with_meta(self, meta: TypeMetadata) -> Type { 156 | Type { meta, typ: self } 157 | } 158 | } 159 | 160 | #[derive(Clone, Debug, PartialEq)] 161 | struct Struct { 162 | // each field must carry some struct-specific metadata 163 | // (on top of metadata attached to the type) 164 | fields: Map, 165 | } 166 | 167 | impl Struct { 168 | /// Build a struct from an object-like OpenApi type 169 | /// We look recursively inside the object definition 170 | /// and nested schema definitions are added to the index 171 | fn from_objlike_recursive( 172 | obj: &T, 173 | path: ApiPath, 174 | type_index: &mut TypeLookup, 175 | ) -> Result { 176 | let mut fields = Map::new(); 177 | let required_args: Set = obj.required().iter().cloned().collect(); 178 | for (name, schemaref) in obj.properties() { 179 | let schemaref = schemaref.clone().unbox(); 180 | let path = path.clone().push(name); 181 | let ty = build_type_recursive(&schemaref, path.clone(), type_index)?; 182 | let type_path = TypePath::from(path); 183 | assert!(type_index.insert(type_path.clone(), ty.clone()).is_none()); 184 | let meta = FieldMetadata::default().with_required(required_args.contains(name)); 185 | if let Some(_) = fields.insert(name.parse()?, (meta, type_path)) { 186 | invalid!("Duplicate field name: '{}'", name); 187 | } 188 | } 189 | Ok(Self { fields }) 190 | } 191 | } 192 | 193 | trait ObjectLike { 194 | fn properties(&self) -> &Map>>; 195 | fn additional_properties(&self) -> &Option; 196 | fn required(&self) -> &[String]; 197 | } 198 | 199 | macro_rules! impl_objlike { 200 | ($obj:ty) => { 201 | impl ObjectLike for $obj { 202 | fn properties(&self) -> &Map>> { 203 | &self.properties 204 | } 205 | 206 | fn additional_properties(&self) -> &Option { 207 | &self.additional_properties 208 | } 209 | 210 | fn required(&self) -> &[String] { 211 | &self.required 212 | } 213 | } 214 | }; 215 | } 216 | 217 | impl_objlike!(ObjectType); 218 | impl_objlike!(AnySchema); 219 | 220 | pub(crate) fn walk_api(api: &OpenAPI) -> Result<(TypeLookup, Map>)> { 221 | if !api.security.as_ref().map(|i| i.is_empty()).unwrap_or(true) { 222 | todo!("Security not supported") 223 | } 224 | let mut type_index = TypeLookup::new(); 225 | let dummy = Default::default(); 226 | let components = api.components.as_ref().unwrap_or(&dummy); 227 | walk_component_schemas(&components.schemas, &mut type_index)?; 228 | let routes = walk_paths(&api.paths, &mut type_index, &components)?; 229 | validate_routes(&routes)?; 230 | Ok((type_index, routes)) 231 | } 232 | 233 | fn walk_component_schemas(schema_lookup: &SchemaLookup, type_index: &mut TypeLookup) -> Result<()> { 234 | let path = ApiPath::default().push("components").push("schemas"); 235 | // gather types defined in components 236 | for (name, schema) in schema_lookup { 237 | let path = path.clone().push(name); 238 | let typ = build_type_recursive(&schema, path.clone(), type_index)?; 239 | assert!(type_index.insert(TypePath::from(path), typ).is_none()); 240 | } 241 | Ok(()) 242 | } 243 | 244 | fn walk_paths( 245 | paths: &openapiv3::Paths, 246 | type_index: &mut TypeLookup, 247 | components: &Components, 248 | ) -> Result>> { 249 | let mut routes: Map> = Map::new(); 250 | let api_path = ApiPath::default().push("paths"); 251 | for (path, ref_or_item) in paths.iter() { 252 | let api_path = api_path.clone().push(path); 253 | let route_path = RoutePath::analyse(path)?; 254 | 255 | debug!("Gathering types for path: {:?}", path); 256 | // TODO lookup rather than unwrap 257 | let pathitem = unwrap_ref(&ref_or_item)?; 258 | 259 | if !pathitem.parameters.is_empty() { 260 | todo!("Path-level paraters are not supported") 261 | } 262 | 263 | apply_over_operations(pathitem, |op, method| { 264 | let api_path = api_path.clone().push(method.to_string()); 265 | let route = walk_operation( 266 | op, 267 | method, 268 | api_path.clone(), 269 | &route_path, 270 | type_index, 271 | components, 272 | )?; 273 | routes.entry(path.clone()).or_default().push(route); 274 | Ok(()) 275 | })?; 276 | } 277 | 278 | Ok(routes) 279 | } 280 | 281 | fn apply_over_operations(pathitem: &openapiv3::PathItem, mut func: F) -> Result<()> 282 | where 283 | F: FnMut(&Operation, RawMethod) -> Result<()>, 284 | { 285 | use RawMethod::*; 286 | if let Some(ref op) = pathitem.get { 287 | func(op, Get)?; 288 | } 289 | if let Some(ref op) = pathitem.options { 290 | func(op, Options)?; 291 | } 292 | if let Some(ref op) = pathitem.head { 293 | func(op, Head)?; 294 | } 295 | if let Some(ref op) = pathitem.trace { 296 | func(op, Trace)?; 297 | } 298 | if let Some(ref op) = pathitem.post { 299 | func(op, Post)?; 300 | } 301 | if let Some(ref op) = pathitem.put { 302 | func(op, Put)?; 303 | } 304 | if let Some(ref op) = pathitem.patch { 305 | func(op, Patch)?; 306 | } 307 | if let Some(ref op) = pathitem.delete { 308 | func(op, Delete)?; 309 | } 310 | Ok(()) 311 | } 312 | 313 | fn walk_operation( 314 | op: &Operation, 315 | method: RawMethod, 316 | path: ApiPath, 317 | route_path: &RoutePath, 318 | type_index: &mut TypeLookup, 319 | components: &Components, 320 | ) -> Result { 321 | // TODO: Send in params from path-level 322 | 323 | use Parameter::*; 324 | 325 | if !op.security.as_ref().map(|inner| inner.is_empty()).unwrap_or(true) { 326 | todo!("Security not supported") 327 | } 328 | 329 | let (operation_id, path) = match op.operation_id { 330 | Some(ref op) => op.parse().map(|opid| (opid, path.push(op))), 331 | None => invalid!("Missing operationId for '{}'", route_path), 332 | }?; 333 | 334 | // A LOT of work goes into getting the path and query parameters correct! 335 | 336 | let mut path_params = Map::new(); 337 | let mut query_params = Map::new(); 338 | 339 | let mut expected_route_params: Set<&str> = route_path.path_args().collect(); 340 | let mut duplicate_param_name_check = Set::new(); 341 | 342 | for param in &op.parameters { 343 | // for each parameter we gather the type but we also need to 344 | // collect the Queries and Paths to make the parent Query and Path types 345 | let param = dereference(param, &components.parameters)?; 346 | 347 | let parameter_data = match param { 348 | Path { parameter_data, .. } 349 | | Query { parameter_data, .. } 350 | | Header { parameter_data, .. } 351 | | Cookie { parameter_data, .. } => parameter_data, 352 | }; 353 | 354 | // We use macros here and below to cut down on duplication between path and query params 355 | macro_rules! build_param_type { 356 | ($params: ident, $path: expr) => { 357 | if !duplicate_param_name_check.insert(¶meter_data.name) { 358 | invalid!("Duplicated parameter '{}'", parameter_data.name) 359 | } 360 | let path = path.clone().push($path).push(¶meter_data.name); 361 | let name: Ident = parameter_data.name.parse()?; 362 | let meta = FieldMetadata::default().with_required(parameter_data.required); 363 | $params.insert(name, (meta, TypePath::from(path.clone()))); 364 | match ¶meter_data.format { 365 | ParameterSchemaOrContent::Schema(schema) => { 366 | let typ = build_type_recursive(&schema, path.clone(), type_index)?; 367 | assert!(type_index.insert(TypePath::from(path), typ).is_none()); 368 | } 369 | ParameterSchemaOrContent::Content(_) => todo!(), 370 | } 371 | }; 372 | } 373 | 374 | match param { 375 | Path { .. } => { 376 | if !expected_route_params.remove(parameter_data.name.as_str()) { 377 | invalid!("path parameter '{}' not found in path", parameter_data.name) 378 | } 379 | if !parameter_data.required { 380 | invalid!( 381 | "Path parameter '{}' must be 'required'", 382 | parameter_data.name 383 | ) 384 | } 385 | build_param_type!(path_params, "path"); 386 | } 387 | Query { .. } => { 388 | build_param_type!(query_params, "query"); 389 | } 390 | Header { .. } => todo!(), 391 | Cookie { .. } => todo!(), 392 | }; 393 | } 394 | 395 | if !expected_route_params.is_empty() { 396 | invalid!( 397 | "Not enough path parameters specified (Missing: {:?})", 398 | expected_route_params 399 | ) 400 | } 401 | 402 | macro_rules! type_from_params { 403 | ($params: ident, $path: expr) => { 404 | if $params.is_empty() { 405 | None 406 | } else { 407 | // construct a path param type, if any 408 | // This will be used as an Extractor in actix-web 409 | let typ = TypeInner::Struct(Struct { 410 | fields: $params.clone(), 411 | }) 412 | .with_meta(TypeMetadata::default().with_visibility(Visibility::Private)); 413 | let type_path = TypePath::from(path.clone().push($path)); 414 | let exists = type_index 415 | .insert(type_path.clone(), ReferenceOr::Item(typ)) 416 | .is_some(); 417 | assert!(!exists); 418 | Some((type_path, $params)) 419 | } 420 | }; 421 | } 422 | 423 | let path_params = type_from_params!(path_params, "path"); 424 | let query_params = type_from_params!(query_params, "query"); 425 | 426 | let body_path: Option = op 427 | .request_body 428 | .as_ref() 429 | .map::>, _>(|reqbody| { 430 | let path = path.clone().push("request_body"); 431 | let reqbody = dereference(reqbody, &components.request_bodies)?; 432 | let path: Option = walk_contents(&reqbody.content, path.clone(), type_index)?; 433 | Ok(path) 434 | }) 435 | .transpose()? 436 | .flatten(); 437 | 438 | let method = Method::from_raw(method, body_path)?; 439 | 440 | let responses = walk_responses(&op.responses, path, type_index, components)?; 441 | 442 | let route = Route::new( 443 | op.summary.clone(), 444 | op.description.clone(), 445 | operation_id, 446 | method, 447 | route_path.clone(), 448 | path_params, 449 | query_params, 450 | responses, 451 | ); 452 | 453 | Ok(route) 454 | } 455 | 456 | fn walk_contents( 457 | content: &Map, 458 | path: ApiPath, 459 | type_index: &mut TypeLookup, 460 | ) -> Result> { 461 | if content.len() > 1 { 462 | todo!("Can't have more than one content type"); 463 | } 464 | content 465 | .iter() 466 | .next() 467 | .and_then(|(contentty, mediaty)| { 468 | if contentty != "application/json" { 469 | todo!("Content other than application/json not supported") 470 | } 471 | mediaty.schema.as_ref().map(|schema| { 472 | let typ = build_type_recursive(schema, path.clone(), type_index)?; 473 | assert!(type_index 474 | .insert(TypePath::from(path.clone()), typ) 475 | .is_none()); 476 | Ok(path.into()) 477 | }) 478 | }) 479 | .transpose() 480 | } 481 | 482 | fn walk_responses( 483 | resps: &openapiv3::Responses, 484 | path: ApiPath, 485 | type_index: &mut TypeLookup, 486 | components: &Components, 487 | ) -> Result { 488 | let with_codes: Map = resps 489 | .responses 490 | .iter() 491 | .map(|(code, resp)| { 492 | let code = match code { 493 | ApiStatusCode::Code(v) => StatusCode::from_u16(*v) 494 | .map_err(|_| Error::Validation(format!("Unknown status code '{}'", v))), 495 | ApiStatusCode::Range(v) => invalid!("Status code ranges not supported '{}'", v), 496 | }?; 497 | let resp = dereference(resp, &components.responses)?; 498 | walk_response( 499 | resp, 500 | path.clone().push(code.as_u16().to_string()), 501 | type_index, 502 | ) 503 | .map(|pth| (code, pth)) 504 | }) 505 | .collect::>()?; 506 | 507 | let default = resps 508 | .default 509 | .as_ref() 510 | .map::, _>(|dflt| { 511 | let resp = dereference(dflt, &components.responses)?; 512 | let path = path.clone().push("default"); 513 | walk_response(&resp, path, type_index) 514 | }) 515 | .transpose()?; 516 | 517 | Ok(Responses { 518 | with_codes, 519 | default, 520 | }) 521 | } 522 | 523 | fn walk_response( 524 | resp: &openapiv3::Response, 525 | path: ApiPath, 526 | type_index: &mut TypeLookup, 527 | ) -> Result { 528 | if !resp.headers.is_empty() { 529 | todo!("response headers not supported") 530 | } 531 | if !resp.links.is_empty() { 532 | todo!("response links not supported") 533 | } 534 | let type_path = walk_contents(&resp.content, path, type_index)?; 535 | Ok(Response { 536 | type_path, 537 | description: resp.description.clone(), 538 | }) 539 | } 540 | 541 | /// Build a type from a schema definition 542 | // We do not try to be too clever here, mostly just build the type in 543 | // the obvious way and return it. References are left unchanged, we will 544 | // dereference them later. However, sometimes we will need to 545 | // recursively build an inner type (e.g. for arrays), at which point the outer-type will 546 | // need to add the inner-type to the registry to make sure it can use it 547 | fn build_type_recursive( 548 | ref_or_schema: &ReferenceOr, 549 | path: ApiPath, 550 | type_index: &mut TypeLookup, 551 | ) -> Result> { 552 | let schema = match ref_or_schema { 553 | ReferenceOr::Reference { reference } => { 554 | return Ok(ReferenceOr::Reference { 555 | reference: reference.clone(), 556 | }) 557 | } 558 | ReferenceOr::Item(item) => item, 559 | }; 560 | let meta = schema.schema_data.clone(); 561 | 562 | if let Some(_) = meta.default { 563 | todo!("Default values not supported (location: '{}')", path) 564 | } 565 | 566 | if let Some(_) = meta.discriminator { 567 | todo!("Discriminator values not supported (location: '{}')", path) 568 | } 569 | 570 | let ty = match &schema.schema_kind { 571 | SchemaKind::Type(ty) => ty, 572 | SchemaKind::Any(obj) => { 573 | if let Some(_) = obj.additional_properties() { 574 | todo!("Additional properties not supported (location: '{}')", path) 575 | } 576 | 577 | let inner = if obj.properties.is_empty() { 578 | TypeInner::Any 579 | } else { 580 | TypeInner::Struct(Struct::from_objlike_recursive(obj, path, type_index)?) 581 | }; 582 | return Ok(ReferenceOr::Item(inner.with_meta(meta.into()))); 583 | } 584 | SchemaKind::AllOf { all_of: schemas } => { 585 | let allof_types = schemas 586 | .iter() 587 | .enumerate() 588 | .map(|(ix, schema)| { 589 | let path = path.clone().push(format!("AllOf_{}", ix)); 590 | // Note that we do NOT automatically add the sub-types to 591 | // the registry as they may not be needed 592 | build_type_recursive(schema, path, type_index) 593 | }) 594 | .collect::>>()?; 595 | // It's an 'allOf', so at some point we need to costruct a new type by 596 | // combining other types together. We can't do this yet, however - 597 | // we will have to wait until we have 'seen' (i.e. walked) every type 598 | return Ok(ReferenceOr::Item( 599 | TypeInner::AllOf(allof_types).with_meta(meta.into()), 600 | )); 601 | } 602 | SchemaKind::AnyOf { any_of: schemas } | SchemaKind::OneOf { one_of: schemas } => { 603 | let oneof_types = schemas 604 | .iter() 605 | .enumerate() 606 | .map(|(ix, schema)| { 607 | let path = path.clone().push(format!("OneOf_{}", ix)); 608 | let innerty = build_type_recursive(schema, path.clone(), type_index)?; 609 | let type_path = TypePath::from(path); 610 | assert!(type_index 611 | .insert(type_path.clone(), innerty.clone()) 612 | .is_none()); 613 | Ok(type_path) 614 | }) 615 | .collect::>>()?; 616 | return Ok(ReferenceOr::Item( 617 | TypeInner::OneOf(oneof_types).with_meta(meta.into()), 618 | )); 619 | } 620 | SchemaKind::Not { .. } => todo!("'not' is not yet supported") 621 | }; 622 | let typ = match ty { 623 | // TODO make enums from string 624 | // TODO fail on other validation 625 | // handle the primitives in a straightforward way 626 | ApiType::String(strty) => { 627 | if !strty.format.is_empty() { 628 | todo!("String formats not supported (location: '{}')", path) 629 | } 630 | 631 | if let Some(_) = strty.pattern { 632 | todo!("String patterns not supported (location: '{}')", path) 633 | } 634 | 635 | if !strty.enumeration.is_empty() { 636 | let enums: Vec<_> = strty 637 | .enumeration 638 | .iter() 639 | .map(|t| t.as_ref().unwrap().clone()) 640 | .collect(); 641 | TypeInner::StringEnum(enums) 642 | } else { 643 | TypeInner::Primitive(Primitive::String) 644 | } 645 | } 646 | ApiType::Number(_) => TypeInner::Primitive(Primitive::F64), 647 | ApiType::Integer(_) => TypeInner::Primitive(Primitive::I64), 648 | ApiType::Boolean {} => TypeInner::Primitive(Primitive::Bool), 649 | ApiType::Array(arr) => { 650 | // build the inner-type 651 | let items = arr.items.as_ref().unwrap().clone().unbox(); 652 | let path = path.clone().push("array"); 653 | let innerty = build_type_recursive(&items, path.clone(), type_index)?; 654 | // add inner type to the registry 655 | assert!(type_index 656 | .insert(TypePath::from(path), innerty.clone()) 657 | .is_none()); 658 | TypeInner::Array(Box::new(innerty)) 659 | } 660 | ApiType::Object(obj) => { 661 | if let Some(_) = obj.additional_properties() { 662 | todo!("Additional properties not supported") 663 | } 664 | TypeInner::Struct(Struct::from_objlike_recursive(obj, path, type_index)?) 665 | } 666 | }; 667 | Ok(ReferenceOr::Item(typ.with_meta(meta.into()))) 668 | } 669 | 670 | /// Generate code that defines a `struct` or `type` alias for each object found 671 | /// in the OpenAPI definition 672 | pub(crate) fn generate_rust_types(types: &TypeLookup) -> Result { 673 | let mut tokens = TokenStream::new(); 674 | for (typepath, typ) in types { 675 | let def = generate_rust_type(typepath, typ, types)?; 676 | tokens.extend(def); 677 | } 678 | Ok(tokens) 679 | } 680 | 681 | /// Generate code that defines a `struct` or `type` alias for each object found 682 | /// in the OpenAPI definition 683 | fn generate_rust_type( 684 | type_path: &TypePath, 685 | typ: &ReferenceOr, 686 | lookup: &TypeLookup, 687 | ) -> Result { 688 | debug!("generate: {}", ApiPath::from(type_path.clone())); 689 | let name = type_path.canonicalize(); 690 | let def = match typ { 691 | ReferenceOr::Reference { reference } => { 692 | let refs = TypePath::from_reference(reference)?.canonicalize(); 693 | quote! { 694 | // Simply alias this type to the referred type 695 | type #name = #refs; 696 | } 697 | } 698 | ReferenceOr::Item(typ) => { 699 | use TypeInner as T; 700 | match &typ.typ { 701 | T::Any => { 702 | let descr = typ.meta.description(); 703 | quote! { 704 | #descr 705 | // could be 'any' valid json 706 | type #name = JsonValue; 707 | } 708 | } 709 | T::AllOf(parts) => { 710 | let strukt = combine_types(parts, lookup)?; 711 | let typ = 712 | ReferenceOr::Item(TypeInner::Struct(strukt).with_meta(typ.meta.clone())); 713 | // Defer to struct impl 714 | generate_rust_type(type_path, &typ, lookup)? 715 | } 716 | T::OneOf(variants) => { 717 | let variants: Vec<_> = variants 718 | .iter() 719 | .enumerate() 720 | .map(|(ix, var)| { 721 | Variant::new(format!("V{}", ix + 1).parse().unwrap()) 722 | .type_path(Some(var.clone())) 723 | }) 724 | .collect(); 725 | generate_enum_def(&name, &typ.meta, &variants, None, true) 726 | } 727 | T::Primitive(p) => { 728 | let id = crate::ident(p); 729 | let descr = typ.meta.description(); 730 | let ty = if typ.meta.nullable { 731 | quote! { 732 | Option<#id> 733 | } 734 | } else { 735 | quote! { 736 | #id 737 | } 738 | }; 739 | quote! { 740 | #descr 741 | type #name = #ty; 742 | } 743 | } 744 | T::StringEnum(variants) => { 745 | let variants: Vec<_> = variants 746 | .iter() 747 | .map(|var| { 748 | let var = 749 | Variant::new(var.to_pascal_case().parse()?).rename(var.clone()); 750 | Ok(var) 751 | }) 752 | .collect::>()?; 753 | generate_enum_def(&name, &typ.meta, &variants, None, false) 754 | } 755 | T::Array(_) => { 756 | let path = ApiPath::from(type_path.clone()); 757 | let inner_path = TypePath::from(path.push("array")); 758 | assert!(lookup.contains_key(&inner_path)); 759 | let inner_path = inner_path.canonicalize(); 760 | let descr = typ.meta.description(); 761 | if typ.meta.nullable { 762 | quote! { 763 | #descr 764 | type #name = Option>; 765 | } 766 | } else { 767 | quote! { 768 | #descr 769 | type #name = Vec<#inner_path>; 770 | } 771 | } 772 | } 773 | T::Struct(strukt) => { 774 | generate_struct_def(strukt, &name, type_path, &typ.meta, lookup)? 775 | } 776 | } 777 | } 778 | }; 779 | Ok(def) 780 | } 781 | 782 | fn generate_struct_def( 783 | strukt: &Struct, 784 | name: &TypeName, 785 | type_path: &TypePath, 786 | meta: &TypeMetadata, 787 | lookup: &TypeLookup, 788 | ) -> Result { 789 | let fieldnames: Vec<_> = strukt.fields.iter().map(|(field, _)| field).collect(); 790 | let visibility = meta.visibility; 791 | let descr = meta.description(); 792 | let fields: Vec = strukt 793 | .fields 794 | .iter() 795 | .map(|(_field, (meta, field_type_path))| { 796 | // Tricky bit. The field may be 'not required', from POV of the struct 797 | // but also the type itself may be nullable. This is supposed to represent 798 | // how in javascript an object key may be 'missing', or it may be 'null' 799 | // This doesn't work well for Rust which has no concept of 'missing', 800 | // so both these cases are covered by making it and Option. But this 801 | // means if a field is both 'not required' and 'nullable', we run risk of 802 | // doubling the type up as Option>. We hack around this by reaching 803 | // into to type and combining the two attributes into one 804 | 805 | let ref_or = lookup.get(&field_type_path).unwrap(); // this lookup should not fail 806 | let field_type = lookup_type_recursive(ref_or, lookup)?; // this one can 807 | let required = meta.required; 808 | let nullable = field_type.meta.nullable; 809 | let field_type_name = field_type_path.canonicalize(); 810 | let def = if nullable || (required && !nullable) { 811 | quote! {#field_type_name} 812 | } else { 813 | quote! {Option<#field_type_name>} 814 | }; 815 | Ok(def) 816 | }) 817 | .collect::>()?; 818 | let derives = get_derive_tokens(); 819 | // Another tricky bit. We have to create 'some' type with the 820 | // canonical name, either concrete struct or alias, so that it can be 821 | // referenced from elsewhere. But we also need want to potentially 822 | // rename the type to the 'title', and also perhaps make it an 'nullable' 823 | // which amounts to creating an inner type and then aliasing to Option 824 | // So now we handle these various cases 825 | let tokens = match (&meta.title, meta.nullable) { 826 | (None, false) => { 827 | quote! { 828 | #descr 829 | #derives 830 | #visibility struct #name { 831 | #(pub #fieldnames: #fields),* 832 | } 833 | } 834 | } 835 | (None, true) => { 836 | let new_path = TypePath::from(ApiPath::from(type_path.clone()).push("opt")); 837 | let new_name = new_path.canonicalize(); 838 | quote! { 839 | #descr 840 | #derives 841 | #visibility struct #new_name { 842 | #(pub #fieldnames: #fields),* 843 | } 844 | #visibility type #name = Option<#new_name>; 845 | } 846 | } 847 | (Some(title), false) => { 848 | let new_name = title.parse::()?; 849 | quote! { 850 | #descr 851 | #derives 852 | #visibility struct #new_name { 853 | #(pub #fieldnames: #fields),* 854 | } 855 | // This alias is not visible because we prefer to use new_name 856 | type #name = #new_name; 857 | } 858 | } 859 | (Some(title), true) => { 860 | let new_name = title.parse::()?; 861 | quote! { 862 | #descr 863 | #derives 864 | #visibility struct #new_name { 865 | #(pub #fieldnames: #fields),* 866 | } 867 | #visibility type #name = Option<#new_name>; 868 | } 869 | } 870 | }; 871 | Ok(tokens) 872 | } 873 | 874 | /// TODO If there are multiple different error types, construct an 875 | /// enum to hold them all. If there is only one or none, don't bother. 876 | pub(crate) fn generate_enum_def( 877 | name: &TypeName, 878 | meta: &TypeMetadata, 879 | variants: &[Variant], 880 | dflt: Option<&Variant>, 881 | untagged: bool, 882 | ) -> TokenStream { 883 | if variants.is_empty() && dflt.is_none() { 884 | // Should not be able to get here (?) 885 | panic!("Enum '{}' has no variants", name); 886 | } 887 | 888 | // should serde do untagged serialization? 889 | // (The answer should be 'no', unless it is a OneOf/AnyOf type) 890 | let serde_tag = if untagged { 891 | Some(quote! {#[serde(untagged)]}) 892 | } else { 893 | None 894 | }; 895 | 896 | // Special-case the default variant 897 | let default = dflt.map(|variant| { 898 | let docs = variant.description.as_ref().map(doc_comment); 899 | match &variant.type_path { 900 | None => quote! {Default { status_code: u16 }}, 901 | Some(path) => { 902 | let varty = path.canonicalize(); 903 | quote! { 904 | #docs 905 | Default { 906 | status_code: u16, 907 | body: #varty 908 | } 909 | } 910 | } 911 | } 912 | }); 913 | let derives = get_derive_tokens(); 914 | let visibility = meta.visibility; 915 | let descr = meta.description(); 916 | quote! { 917 | #descr 918 | #derives 919 | #serde_tag 920 | #visibility enum #name { 921 | #(#variants,)* 922 | #default 923 | } 924 | } 925 | } 926 | 927 | fn combine_types(parts: &[ReferenceOr], lookup: &TypeLookup) -> Result { 928 | // We do the combination in a simplistic way: assume parent types are structs, 929 | // and add all the fields into a new struct. Reject duplicates 930 | let mut base = Map::new(); 931 | for part in parts.iter() { 932 | let typ = lookup_type_recursive(part, lookup)?; 933 | match &typ.typ { 934 | TypeInner::Struct(strukt) => { 935 | for (field, required) in &strukt.fields { 936 | if let Some(_) = base.insert(field, required) { 937 | // duplicate field 938 | invalid!("Duplicate field '{}'", field); 939 | } 940 | } 941 | } 942 | _ => todo!("Non-struct allOf combinations are not supported"), 943 | } 944 | } 945 | Ok(Struct { 946 | fields: base 947 | .into_iter() 948 | .map(|(n, m)| (n.clone(), m.clone())) 949 | .collect(), 950 | }) 951 | } 952 | 953 | #[cfg(test)] 954 | mod tests { 955 | use super::*; 956 | use std::fs; 957 | 958 | #[test] 959 | fn test_gather_types() { 960 | let yaml = "../examples/petstore-expanded/petstore-expanded.yaml"; 961 | // let yaml = "../examples/petstore/petstore.yaml"; 962 | let yaml = fs::read_to_string(yaml).unwrap(); 963 | let api: OpenAPI = serde_yaml::from_str(&yaml).unwrap(); 964 | let (types, _routes) = walk_api(&api).unwrap(); 965 | 966 | #[allow(unused_mut)] 967 | let mut code = generate_rust_types(&types).unwrap().to_string(); 968 | 969 | #[cfg(feature = "rustfmt")] 970 | { 971 | code = crate::prettify_code(code).unwrap(); 972 | } 973 | println!("{}", code); 974 | panic!() 975 | } 976 | } 977 | -------------------------------------------------------------------------------- /hsr-codegen/tests/codegen.rs: -------------------------------------------------------------------------------- 1 | #![recursion_limit = "256"] 2 | 3 | use hsr_codegen; 4 | use yansi::Paint; 5 | 6 | use quote::quote; 7 | 8 | fn assert_diff(left: &str, right: &str) { 9 | use diff::Result::*; 10 | if left == right { 11 | return; 12 | } 13 | for d in diff::lines(left, right) { 14 | match d { 15 | Left(l) => println!("{}", Paint::red(format!("- {}", l))), 16 | Right(r) => println!("{}", Paint::green(format!("+ {}", r))), 17 | Both(l, _) => println!("= {}", l), 18 | } 19 | } 20 | panic!("Bad diff") 21 | } 22 | 23 | #[test] 24 | fn build_types_simple() { 25 | let _ = env_logger::init(); 26 | let yaml = "../example-api/petstore.yaml"; 27 | let code = hsr_codegen::generate_from_yaml_file(yaml).unwrap(); 28 | 29 | // This is the complete expected code generation output 30 | // It should compile! 31 | let mut expect = quote! { 32 | use hsr::actix_web::{App, HttpServer}; 33 | use hsr::actix_web::web::{self, Json as AxJson, Query as AxQuery, Path as AxPath, Data as AxData}; 34 | use hsr::futures3::future::{BoxFuture as BoxFuture3, FutureExt, TryFutureExt}; 35 | use hsr::futures1::Future as Future1; 36 | 37 | #[derive(Debug, Clone, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)] 38 | pub struct Error { 39 | pub code: i64, 40 | pub message: String 41 | } 42 | 43 | #[derive(Debug, Clone, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)] 44 | pub struct NewPet { 45 | pub name: String, 46 | pub tag: Option 47 | } 48 | 49 | #[derive(Debug, Clone, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)] 50 | pub struct Pet { 51 | pub id: i64, 52 | pub name: String, 53 | pub tag: Option 54 | } 55 | 56 | pub type Pets = Vec; 57 | 58 | #[derive(Debug, Clone, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)] 59 | pub struct SomeConflict { 60 | pub message: String 61 | } 62 | 63 | #[derive(Debug, Clone, PartialEq, PartialOrd, serde::Serialize, serde::Deserialize)] 64 | pub enum CreatePetError { 65 | E403, 66 | E409(SomeConflict), 67 | Default(Error) 68 | } 69 | 70 | pub trait Api: Send + Sync + 'static { 71 | fn new() -> Self; 72 | fn get_all_pets(&self, limit: Option) -> BoxFuture3; 73 | fn create_pet(&self, new_pet: NewPet) -> BoxFuture3>; 74 | fn get_pet(&self, pet_id: i64) -> BoxFuture3>; 75 | } 76 | 77 | fn get_all_pets(data: AxData, limit: AxQuery>) 78 | -> impl Future1, Error = Void> { 79 | data.get_all_pets(limit.into_inner()) 80 | .map(Ok) 81 | .map(|res| res.map(AxJson)) 82 | .boxed() 83 | .compat() 84 | } 85 | 86 | fn create_pet( 87 | data: AxData, 88 | new_pet: AxJson, 89 | ) -> impl Future1 { 90 | data.create_pet(new_pet.into_inner()) 91 | .boxed() 92 | .compat() 93 | } 94 | 95 | fn get_pet( 96 | data: AxData, 97 | pet_id: AxPath, 98 | ) -> impl Future1, Error = Error> { 99 | data.get_pet(pet_id.into_inner()) 100 | .map(|res| res.map(AxJson)) 101 | .boxed() 102 | .compat() 103 | } 104 | 105 | pub fn serve() -> std::io::Result<()> { 106 | let api = AxData::new(A::new()); 107 | HttpServer::new(move || { 108 | App::new() 109 | .register_data(api.clone()) 110 | .service( 111 | web::resource("/pets") 112 | .route(web::get().to_async(get_all_pets::)) 113 | .route(web::post().to_async(create_pet::)) 114 | ) 115 | .service( 116 | web::resource("/pets/{petId}") 117 | .route(web::get().to_async(get_pet::)) 118 | ) 119 | }) 120 | .bind("127.0.0.1:8000")? 121 | .run() 122 | } 123 | } 124 | .to_string(); 125 | #[cfg(feature = "rustfmt")] 126 | { 127 | expect = hsr_codegen::prettify_code(expect).unwrap(); 128 | } 129 | assert_diff(&code, &expect); 130 | } 131 | -------------------------------------------------------------------------------- /hsr-codegen/ui-template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | OpenAPI 5 | 6 | 7 | 8 |
9 | 10 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /hsr/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "hsr" 3 | version = "0.4.0" 4 | authors = ["Alex Whitney "] 5 | edition = "2021" 6 | description = "Build fast HTTP APIs fast, with Rust + OpenAPI" 7 | repository = "https://github.com/adwhit/hsr" 8 | homepage = "https://github.com/adwhit/hsr" 9 | keywords = ["swagger", "openapi", "web", "REST", "actix-web"] 10 | license = "MIT" 11 | readme = "../README.md" 12 | 13 | [dependencies] 14 | futures = "0.3.25" 15 | actix-web = { version = "4.2.1", features = ["openssl"] } 16 | actix-http = "3.2.2" 17 | awc = "3.0.1" 18 | actix-rt = "2.7.0" 19 | url = "2.3.1" 20 | serde_urlencoded = "0.7.1" 21 | serde_derive = "1.0.147" 22 | openssl = "0.10.42" 23 | async-trait = "0.1.58" 24 | derive_more = "0.99.17" 25 | thiserror = "1.0.37" 26 | serde_json = "1.0.87" 27 | -------------------------------------------------------------------------------- /hsr/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! HSR runtime helpers and types 2 | 3 | #[macro_use] 4 | #[allow(unused_imports)] 5 | extern crate serde_derive; 6 | pub use serde_derive::{Deserialize, Serialize}; 7 | 8 | // We have a tonne of public imports. We places them here and make them public 9 | // so that the user doesn't have to faff around adding them all and making sure 10 | // the versions are all compatible 11 | pub use actix_http; 12 | pub use actix_rt; 13 | pub use actix_web; 14 | pub use async_trait; 15 | pub use awc; 16 | pub use futures; 17 | pub use serde_json; 18 | pub use serde_urlencoded; 19 | pub use url; 20 | 21 | pub use openssl; 22 | 23 | pub use url::Url; 24 | 25 | // We re-export this type as it is used in all the trait functions 26 | use actix_http::StatusCode; 27 | use actix_web::{Error as ActixError, HttpResponse}; 28 | 29 | /// Associate an http status code with a type. Defaults to 501 Internal Server Error 30 | pub trait HasStatusCode { 31 | /// The http status code associated with the type 32 | fn status_code(&self) -> StatusCode; 33 | } 34 | 35 | /// Errors that may be returned by the client, apart from those explicitly 36 | /// specified in the spec. 37 | /// 38 | /// This will handle bad connections, path errors, unreconginized statuses 39 | /// and any other 'unexpected errors' 40 | #[derive(Debug, thiserror::Error)] 41 | pub enum ClientError { 42 | #[error("Unknown status code: {:?}", _0)] 43 | BadStatus(StatusCode), 44 | #[error("Actix error: {}", _0)] 45 | Actix(#[from] ActixError), 46 | } 47 | 48 | pub fn configure_spec( 49 | cfg: &mut actix_web::web::ServiceConfig, 50 | spec: &'static str, 51 | ui: &'static str, 52 | ) { 53 | use actix_web::http::header::ContentType; 54 | async fn serve_spec(spec: &'static str) -> HttpResponse { 55 | HttpResponse::Ok() 56 | .insert_header(ContentType::json()) 57 | .body(spec.to_owned()) 58 | } 59 | async fn serve_ui(ui: &'static str) -> HttpResponse { 60 | HttpResponse::Ok() 61 | .insert_header(ContentType::json()) 62 | .body(ui.to_owned()) 63 | } 64 | // Add route serving up the json spec 65 | cfg.route( 66 | "/spec.json", 67 | actix_web::web::get().to( 68 | move || serve_spec(spec), // spec.clone() 69 | ), 70 | ) 71 | // Add route serving up the rendered ui 72 | .route("/ui.html", actix_web::web::get().to(move || serve_ui(ui))); 73 | } 74 | 75 | pub struct Config { 76 | pub host: Url, 77 | pub ssl: Option, 78 | } 79 | 80 | impl Config { 81 | pub fn with_host(host: Url) -> Self { 82 | Self { host, ssl: None } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /test/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "test" 3 | version = "0.1.0" 4 | authors = ["Alex Whitney "] 5 | edition = "2021" 6 | 7 | [build-dependencies] 8 | hsr-codegen = { path = "../hsr-codegen" } 9 | 10 | [dependencies] 11 | hsr = { path = "../hsr" } 12 | serde = "1.0.147" 13 | env_logger = "0.9.3" 14 | actix-rt = "2.7.0" 15 | serde_json = "1.0.87" 16 | 17 | [features] 18 | pretty = ["hsr-codegen/pretty"] 19 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | ## Test 2 | 3 | This spec and the accompanying code attempt to check the various aspects of 4 | code generation and execution. Generally, if a bug is found, a regression 5 | test for it should be added here. 6 | 7 | ## Run 8 | 9 | ``` sh 10 | cargo run 11 | ``` 12 | -------------------------------------------------------------------------------- /test/build.rs: -------------------------------------------------------------------------------- 1 | use hsr_codegen; 2 | use std::io::Write; 3 | 4 | fn main() { 5 | let code = hsr_codegen::generate_from_yaml_file("test-spec.yaml").expect("Generation failure"); 6 | 7 | let out_dir = std::env::var("OUT_DIR").unwrap(); 8 | let dest_path = std::path::Path::new(&out_dir).join("api.rs"); 9 | let mut f = std::fs::File::create(&dest_path).unwrap(); 10 | 11 | write!(f, "{}", code).unwrap(); 12 | println!("cargo:rerun-if-changed=test-spec.yaml"); 13 | } 14 | -------------------------------------------------------------------------------- /test/src/lib.rs: -------------------------------------------------------------------------------- 1 | #[allow(non_snake_case)] 2 | pub mod api { 3 | include!(concat!(env!("OUT_DIR"), "/api.rs")); 4 | } 5 | -------------------------------------------------------------------------------- /test/src/main.rs: -------------------------------------------------------------------------------- 1 | use test::api::{self, client, server, TestApi}; 2 | 3 | struct Api; 4 | 5 | #[hsr::async_trait::async_trait(?Send)] 6 | impl TestApi for Api { 7 | async fn get_status(&self) -> api::GetStatus { 8 | api::GetStatus::Ok 9 | } 10 | 11 | async fn set_status(&self, status: Option) -> api::SetStatus { 12 | api::SetStatus::Ok(status) 13 | } 14 | 15 | async fn two_path_params(&self, name: String, age: i64) -> api::TwoPathParams { 16 | api::TwoPathParams::Ok(api::Hello { 17 | myName: name, 18 | my_age: Some(age), 19 | }) 20 | } 21 | 22 | async fn two_query_params(&self, my_name: String, my_age: Option) -> api::TwoQueryParams { 23 | api::TwoQueryParams::Ok(api::Hello { 24 | myName: my_name, 25 | my_age, 26 | }) 27 | } 28 | 29 | async fn just_default(&self) -> api::JustDefault { 30 | api::JustDefault::Default { 31 | status_code: 200, 32 | body: hello(), 33 | } 34 | } 35 | 36 | async fn ok_error_default(&self, return_code: i64) -> api::OkErrorDefault { 37 | match return_code { 38 | 200 => api::OkErrorDefault::Ok, 39 | 400 => api::OkErrorDefault::BadRequest, 40 | other => api::OkErrorDefault::Default { 41 | status_code: other as u16, 42 | }, 43 | } 44 | } 45 | 46 | async fn nestedResponse(&self) -> api::NestedResponse { 47 | api::NestedResponse::Ok(api::NestedResponse200 { 48 | first: api::FirstResponse { 49 | second: api::NestedResponse200FirstSecond {}, 50 | }, 51 | }) 52 | } 53 | 54 | async fn anything_goes(&self, one_of: api::OneOfTest) -> api::AnythingGoes { 55 | api::AnythingGoes::Ok(one_of) 56 | } 57 | } 58 | 59 | // Quickly generate some data 60 | fn hello() -> api::Hello { 61 | api::Hello { 62 | myName: "Alex".into(), 63 | my_age: Some(33), 64 | } 65 | } 66 | 67 | #[allow(dead_code)] 68 | fn nullable_struct() -> api::NullableStruct { 69 | Some(api::NullableStructOpt { 70 | this: "string".into(), 71 | that: Some(123), 72 | other: Some(vec!["string".into()]), 73 | flooglezingle: Some(true), 74 | }) 75 | } 76 | 77 | fn all_of_test() -> api::AllOfTest { 78 | let blob = serde_json::json!({ 79 | "myName": "Alex", 80 | "height": 1.88, 81 | "feet_info": { 82 | "number_of_toes": 6, 83 | "webbed": true, 84 | "the_rest": "blah blah blah blah" 85 | } 86 | }); 87 | serde_json::from_value(blob).unwrap() 88 | } 89 | 90 | // TODO make this into a 'normal' rust test suite not just a big main function 91 | 92 | #[actix_rt::main] 93 | async fn main() -> Result<(), Box> { 94 | env_logger::init(); 95 | 96 | let uri: hsr::Url = "http://127.0.0.1:8000".parse().unwrap(); 97 | let uri2 = uri.clone(); 98 | 99 | std::thread::spawn(move || { 100 | println!("Serving at '{}'", uri); 101 | let system = hsr::actix_rt::System::new(); 102 | let server = server::serve(Api, hsr::Config::with_host(uri)); 103 | system.block_on(server).unwrap(); 104 | }); 105 | 106 | std::thread::sleep(std::time::Duration::from_millis(100)); 107 | 108 | let client = client::Client::new(uri2); 109 | println!("Testing endpoints"); 110 | 111 | let _ = all_of_test(); 112 | 113 | assert_eq!(client.get_status().await?, api::GetStatus::Ok); 114 | 115 | { 116 | assert_eq!( 117 | client.set_status(Some("some-status".into())).await?, 118 | api::SetStatus::Ok(Some("some-status".into())) 119 | ); 120 | assert_eq!(client.set_status(None).await?, api::SetStatus::Ok(None)); 121 | } 122 | 123 | { 124 | let echo = client.two_path_params("Alex".to_string(), 33).await?; 125 | assert_eq!(echo, api::TwoPathParams::Ok(hello())); 126 | } 127 | 128 | { 129 | let echo = client 130 | .two_query_params("Alex".to_string(), Some(33)) 131 | .await?; 132 | assert_eq!(echo, api::TwoQueryParams::Ok(hello())); 133 | 134 | let echo = client.two_query_params("Alex".to_string(), None).await?; 135 | assert_eq!( 136 | echo, 137 | api::TwoQueryParams::Ok(api::Hello { 138 | myName: "Alex".into(), 139 | my_age: None 140 | }) 141 | ); 142 | } 143 | 144 | { 145 | let rtn = client.just_default().await?; 146 | assert_eq!( 147 | rtn, 148 | api::JustDefault::Default { 149 | status_code: 200, 150 | body: hello() 151 | } 152 | ) 153 | } 154 | 155 | { 156 | let rtn = client.ok_error_default(200).await?; 157 | assert_eq!(rtn, api::OkErrorDefault::Ok); 158 | 159 | let rtn = client.ok_error_default(400).await?; 160 | assert_eq!(rtn, api::OkErrorDefault::BadRequest); 161 | 162 | let rtn = client.ok_error_default(201).await?; 163 | assert_eq!(rtn, api::OkErrorDefault::Default { status_code: 201 }); 164 | 165 | let rtn = client.ok_error_default(-1).await?; 166 | assert_eq!(rtn, api::OkErrorDefault::Default { status_code: 500 }); 167 | } 168 | 169 | { 170 | let nested = client.nestedResponse().await?; 171 | assert_eq!( 172 | nested, 173 | api::NestedResponse::Ok(api::NestedResponse200 { 174 | first: api::FirstResponse { 175 | second: api::NestedResponse200FirstSecond {} 176 | } 177 | }) 178 | ); 179 | } 180 | 181 | { 182 | // TODO I doubt this is being serialized properly. Need to send as 'untagged' 183 | let payload = api::OneOfTest::V1(hello()); 184 | let body = client.anything_goes(payload.clone()).await?; 185 | assert_eq!(body, api::AnythingGoes::Ok(payload)); 186 | } 187 | 188 | println!("Success"); 189 | 190 | Ok(()) 191 | } 192 | -------------------------------------------------------------------------------- /test/test-spec.yaml: -------------------------------------------------------------------------------- 1 | openapi: "3.0.0" 2 | info: 3 | version: 1.0.0 4 | title: Test 5 | servers: 6 | - url: http://localhost:8000 7 | 8 | paths: 9 | # Simplest possible route (?) 10 | /status: 11 | get: 12 | operationId: get_status 13 | responses: 14 | '200': 15 | description: "Ok" 16 | 17 | post: 18 | summary: Set The Status 19 | description: I guess we are setting some kind of status 20 | operationId: set_status 21 | requestBody: 22 | description: set status body 23 | required: false 24 | content: 25 | application/json: 26 | schema: 27 | nullable: true 28 | type: string 29 | responses: 30 | '200': 31 | description: "Things are OK" 32 | content: 33 | application/json: 34 | schema: 35 | nullable: true 36 | type: string 37 | 38 | /twoPathParams/{my_name}/{myAge}: 39 | get: 40 | summary: echo name and age 41 | operationId: two_path_params 42 | parameters: 43 | - name: my_name 44 | in: path 45 | required: true 46 | description: User name 47 | schema: 48 | type: string 49 | - name: myAge 50 | in: path 51 | required: true 52 | description: User age 53 | schema: 54 | type: integer 55 | responses: 56 | '200': 57 | description: Hello 200 response 58 | content: 59 | application/json: 60 | schema: 61 | $ref: "#/components/schemas/Hello" 62 | 63 | /twoQueryParams: 64 | get: 65 | summary: echo name and age 66 | operationId: two_query_params 67 | parameters: 68 | - name: myName 69 | in: query 70 | required: true 71 | description: User name 72 | schema: 73 | type: string 74 | - name: my_age 75 | in: query 76 | required: false 77 | description: User age 78 | schema: 79 | type: integer 80 | responses: 81 | '200': 82 | description: Hello 83 | content: 84 | application/json: 85 | schema: 86 | $ref: "#/components/schemas/Hello" 87 | 88 | /justDefault: 89 | get: 90 | operationId: just_default 91 | responses: 92 | default: 93 | description: "Default" 94 | content: 95 | application/json: 96 | schema: 97 | $ref: "#/components/schemas/Hello" 98 | 99 | /okErrorDefault: 100 | get: 101 | parameters: 102 | - name: return_code 103 | in: query 104 | required: true 105 | description: expected status code 106 | schema: 107 | type: integer 108 | operationId: ok_error_default 109 | responses: 110 | '200': 111 | description: "Ok" 112 | '400': 113 | description: "Not Ok" 114 | default: 115 | description: "Default" 116 | 117 | /nestedResponseType: 118 | get: 119 | operationId: nestedResponse 120 | responses: 121 | '200': 122 | title: OkNestedResponse 123 | description: "Ok" 124 | content: 125 | application/json: 126 | schema: 127 | type: object 128 | required: 129 | - first 130 | properties: 131 | first: 132 | title: FirstResponse 133 | type: object 134 | required: 135 | - second 136 | properties: 137 | second: 138 | type: object 139 | 140 | /anythingGoes: 141 | post: 142 | operationId: anything_goes 143 | requestBody: 144 | content: 145 | application/json: 146 | schema: 147 | $ref: '#/components/schemas/OneOfTest' 148 | responses: 149 | '200': 150 | description: "Ok" 151 | content: 152 | application/json: 153 | schema: 154 | $ref: '#/components/schemas/OneOfTest' 155 | 156 | components: 157 | schemas: 158 | # just a boring, normal, not interesting struct 159 | Hello: 160 | description: Hello description 161 | required: 162 | - myName 163 | - my_age 164 | properties: 165 | myName: 166 | nullable: false 167 | type: string 168 | my_age: 169 | nullable: true 170 | type: integer 171 | 172 | # test various combinations of nullable and required 173 | NullableStruct: 174 | # struct itself is nullable 175 | description: This is how we represent a struct that is nullable 176 | nullable: true 177 | required: 178 | - this 179 | - flooglezingle 180 | properties: 181 | # not nullable, required 182 | this: 183 | type: string 184 | # not nullable, not required 185 | that: 186 | nullable: false 187 | type: integer 188 | other: 189 | # nullable, not required 190 | nullable: true 191 | type: array 192 | items: 193 | type: string 194 | # nullable, required 195 | flooglezingle: 196 | nullable: true 197 | type: boolean 198 | 199 | Anything: 200 | type: any 201 | 202 | AllOfTest: 203 | description: Test the AllOf struct generation 204 | allOf: 205 | - $ref: '#/components/schemas/Hello' 206 | - type: object 207 | required: 208 | - height 209 | properties: 210 | height: 211 | type: number 212 | favourite_colour: 213 | type: string 214 | feet_info: 215 | title: FeetInfo 216 | required: 217 | - number_of_toes 218 | - webbed 219 | properties: 220 | number_of_toes: 221 | type: integer 222 | eats_toenails: 223 | type: boolean 224 | webbed: 225 | type: boolean 226 | the_rest: 227 | $ref: '#/components/schemas/Anything' 228 | 229 | OneOfTest: 230 | description: Test the OneOf enum generation 231 | oneOf: 232 | - $ref: '#/components/schemas/Hello' 233 | - $ref: '#/components/schemas/NullableStruct' 234 | - $ref: '#/components/schemas/AllOfTest' 235 | - type: array 236 | items: 237 | required: 238 | - x 239 | properties: 240 | x: 241 | type: number 242 | y: 243 | type: string 244 | 245 | 246 | StringEnum: 247 | type: 248 | string 249 | enum: 250 | - "foo" 251 | - "bar" 252 | - "baz-quxx" 253 | 254 | # HasAdditionalProps: 255 | # additionalProperties: 256 | # true 257 | 258 | # HasADefault: 259 | # type: integer 260 | # default: 10 261 | --------------------------------------------------------------------------------