diff --git a/ruma-api/.builds/beta.yml b/ruma-api/.builds/beta.yml new file mode 100644 index 00000000..c56e6ea1 --- /dev/null +++ b/ruma-api/.builds/beta.yml @@ -0,0 +1,27 @@ +image: archlinux +packages: + - rustup +sources: + - https://github.com/ruma/ruma-api +tasks: + - rustup: | + # We specify --profile minimal because we'd otherwise download docs + rustup toolchain install beta --profile minimal -c rustfmt -c clippy + rustup default beta + - test: | + cd ruma-api + + # We don't want the build to stop on individual failure of independent + # tools, so capture tool exit codes and set the task exit code manually + set +e + + cargo fmt -- --check + fmt_exit=$? + + cargo clippy --all-targets --all-features -- -D warnings + clippy_exit=$? + + cargo test --verbose + test_exit=$? + + exit $(( $fmt_exit || $clippy_exit || $test_exit )) diff --git a/ruma-api/.builds/msrv.yml b/ruma-api/.builds/msrv.yml new file mode 100644 index 00000000..de504675 --- /dev/null +++ b/ruma-api/.builds/msrv.yml @@ -0,0 +1,16 @@ +image: archlinux +packages: + - rustup +sources: + - https://github.com/ruma/ruma-api +tasks: + - rustup: | + # We specify --profile minimal because we'd otherwise download docs + rustup toolchain install 1.40.0 --profile minimal + rustup default 1.40.0 + - test: | + cd ruma-api + + # Only make sure the code builds with the MSRV. Tests can require later + # Rust versions, don't compile or run them. + cargo build --verbose diff --git a/ruma-api/.builds/nightly.yml b/ruma-api/.builds/nightly.yml new file mode 100644 index 00000000..6b597d7d --- /dev/null +++ b/ruma-api/.builds/nightly.yml @@ -0,0 +1,32 @@ +image: archlinux +packages: + - rustup +sources: + - https://github.com/ruma/ruma-api +tasks: + - rustup: | + rustup toolchain install nightly --profile minimal + rustup default nightly + + # Try installing rustfmt & clippy for nightly, but don't fail the build + # if they are not available + rustup component add rustfmt || true + rustup component add clippy || true + - test: | + cd ruma-api + + # We don't want the build to stop on individual failure of independent + # tools, so capture tool exit codes and set the task exit code manually + set +e + + if ( rustup component list | grep -q rustfmt ); then + cargo fmt -- --check + fi + fmt_exit=$? + + if ( rustup component list | grep -q clippy ); then + cargo clippy --all-targets --all-features -- -D warnings + fi + clippy_exit=$? + + exit $(( $fmt_exit || $clippy_exit )) diff --git a/ruma-api/.builds/stable.yml b/ruma-api/.builds/stable.yml new file mode 100644 index 00000000..58a6ce91 --- /dev/null +++ b/ruma-api/.builds/stable.yml @@ -0,0 +1,29 @@ +image: archlinux +packages: + - rustup +sources: + - https://github.com/ruma/ruma-api +tasks: + - rustup: | + # We specify --profile minimal because we'd otherwise download docs + rustup toolchain install stable --profile minimal -c rustfmt -c clippy + rustup default stable + - test: | + cd ruma-api + + # We don't want the build to stop on individual failure of independent + # tools, so capture tool exit codes and set the task exit code manually + set +e + + cargo fmt -- --check + fmt_exit=$? + + cargo clippy --all-targets --all-features -- -D warnings + clippy_exit=$? + + cargo test --all-features --verbose + test_exit=$? + + exit $(( $fmt_exit || $clippy_exit || $test_exit )) + # TODO: Add audit task once cargo-audit binary releases are available. + # See https://github.com/RustSec/cargo-audit/issues/66 diff --git a/ruma-api/.gitignore b/ruma-api/.gitignore new file mode 100644 index 00000000..fa8d85ac --- /dev/null +++ b/ruma-api/.gitignore @@ -0,0 +1,2 @@ +Cargo.lock +target diff --git a/ruma-api/.rustfmt.toml b/ruma-api/.rustfmt.toml new file mode 100644 index 00000000..ad5d280d --- /dev/null +++ b/ruma-api/.rustfmt.toml @@ -0,0 +1,3 @@ +edition = "2018" +merge_imports = true +use_small_heuristics = "Max" diff --git a/ruma-api/CHANGELOG.md b/ruma-api/CHANGELOG.md new file mode 100644 index 00000000..3a0e9900 --- /dev/null +++ b/ruma-api/CHANGELOG.md @@ -0,0 +1,178 @@ +# [unreleased] + +# 0.16.1 + +Bug fixes: + +* Update ruma-serde to 0.2.0, fixing some issues with query string deserialization (some issues + still remain but will be fixed in a semver-compatible version) + +# 0.16.0 + +Breaking changes: + +* Update ruma-identifiers to 0.16.1 +* Remove the `Outgoing` trait and update the `Endpoint` trait and code generation accordingly + +Improvements: + +* Remove dependency on the `url` crate + +# 0.15.1 + +Bug fixes: + +* Write `{}` to the body of responses without body fields (fix from ruma-api-macros) + +# 0.15.0 + +Breaking changes: + +* Emit an error on non-UTF8 characters in path segments + * Before, they would be replaced by the unknown character codepoint +* `FromHttpResponseError` now has a generic parameter for the expected type of + error the homeserver could return + +Improvements: + +* Enable deserialization of unsuccessful responses + +# 0.14.0 + +Breaking changes: + +* Update ruma-api-macros to 0.11.0 + * This includes a fix that uses `TryFrom<&str>` instead of serde_json for path segment + deserialization + +# 0.13.1 + +Improvements: + +* Update ruma-api-macros to 0.10.1 + * `Incoming` types will now implement `Debug` + +# 0.13.0 + +Breaking changes: + +* Instead of one `Error` type, there is now many + * The new types live in their own `error` module + * They provide access to details that were previously hidden +* Our Minimum Supported Rust Version is now 1.40.0 + +# 0.12.1 + +Improvements: + +* Update ruma-api-macros to 0.9.1 to support `#[ruma_api(raw_body)]` + +# 0.12.0 + +Breaking changes: + +* Our Minimum Supported Rust Version is now 1.39.0 +* Support for the server-side use case has been restored. For details, see the documentation for + `ruma_api!`, the new `Outgoing` trait and its derive macro + +# 0.11.2 + +Improvements: + +* Update ruma-api-macros to 0.8.2 + +# 0.11.1 + +Improvements: + +* Update ruma-api-macros to 0.8.1 + +# 0.11.0 + +Breaking changes: + +* To be able to use ruma-event's `EventResult` in ruma-client without large-ish refactorings to ruma-api, we removed support for the server-side use case in ruma-api 0.11.0. It will be added back in a future release. + +Improvements: + +* Our CI now tests ruma-api on Rust 1.34.2, beta and nightly in addition to stable +* Updated syn and quote to 1.0 + +# 0.10.0 + +Breaking changes: + +* The `Endpoint` trait is now implemented directly on the relevant request type rather than having both the request and response be associated types. + +Improvements: + +* ruma-api now re-exports the `ruma_api` macro from ruma-api-macros. Downstream crates no longer need to depend on ruma-api-macros directly. +* The ruma-api and ruma-api-macros repositories have been merged into one Cargo workspace for easier dependency management and development. + +# 0.9.0 + +Breaking changes: + +* The `Request` and `Response` associated types on the `Endpoint` trait are now bounded by `std::convert::TryFrom` instead of `futures::future::FutureFrom`. This was done in preparation for futures 0.3 which does not have this trait. +* The conversions required to and from `http::Request` and `http::Response` for the `Request` and `Response` associated types on the `Endpoint` trait now use `Vec` as the body type. This removes the dependency on hyper. It's possible this will change again in a future release. See https://github.com/rustasync/team/issues/84 for details. + +Improvements: + +* Internal code quality improvements via clippy and rustfmt. + +# 0.8.0 + +Breaking changes: + +* The `Error` type is now an opaque struct that hides implementation details. +* Updates to ruma-identifiers 0.13. + +Improvements: + +* ruma-api now uses clippy to improve code quality. + +# 0.7.0 + +Improvements: + +* ruma-api now runs on stable Rust, requiring version 1.34 or higher. +* Updated all dependencies for upstream improvements. +* Updated all code to use Rust edition 2018. + +# 0.6.0 + +Breaking changes: + +* Hyper has been updated to version 0.12. +* A new variant to the `Error` enum for hyper errors has been added. +* Conversions between this crate's request and response types and the http crate's request and response types are now bidirectional. + +# 0.5.0 + +Breaking changes: + +* Types from hyper have been replaced with types from the http crate. +* The `Error` enum can no longer be matched exhaustively, to allow for future expansion without breaking the crate's API. + +# 0.4.0 + +Breaking changes: + +The crate has been redesign to focus on conversions between an endpoint's request and response types and Hyper request and response types. Implementations are expected to be generated via [ruma-api-macros](https://github.com/ruma/ruma-api-macros). + +# 0.3.0 + +Breaking changes: + +* `Endpoint::router_path` now returns a `&'static str` +* Added new required methods to `Endpoint`: `name`, `description`, `requires_authentication`, and `rate_limited`. + +# 0.2.0 + +Breaking changes: + +* `Endpoint::Query_params` must now be `Deserialize + Serialize`. + +# 0.1.0 + +Initial release. diff --git a/ruma-api/Cargo.toml b/ruma-api/Cargo.toml new file mode 100644 index 00000000..b813d604 --- /dev/null +++ b/ruma-api/Cargo.toml @@ -0,0 +1,34 @@ +[package] +authors = [ + "Jimmy Cuadra ", + "Jonas Platte ", +] +categories = ["api-bindings", "web-programming"] +description = "An abstraction for Matrix API endpoints." +documentation = "https://docs.rs/ruma-api" +homepage = "https://github.com/ruma/ruma-api" +keywords = ["matrix", "chat", "messaging", "ruma"] +license = "MIT" +name = "ruma-api" +readme = "README.md" +repository = "https://github.com/ruma/ruma-api" +version = "0.16.1" +edition = "2018" + +[dependencies] +http = "0.2.1" +percent-encoding = "2.1.0" +ruma-api-macros = { version = "=0.16.1", path = "ruma-api-macros" } +ruma-identifiers = "0.16.1" +ruma-serde = "0.2.0" +serde = { version = "1.0.110", features = ["derive"] } +serde_json = "1.0.53" +strum = "0.18.0" + +[dev-dependencies] +ruma-events = "0.21.1" + +[workspace] +members = [ + "ruma-api-macros", +] diff --git a/ruma-api/LICENSE b/ruma-api/LICENSE new file mode 100644 index 00000000..4d376442 --- /dev/null +++ b/ruma-api/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2016 Jimmy Cuadra + +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: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +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. diff --git a/ruma-api/README.md b/ruma-api/README.md new file mode 100644 index 00000000..a75064c3 --- /dev/null +++ b/ruma-api/README.md @@ -0,0 +1,16 @@ +# ruma-api + +[![crates.io page](https://img.shields.io/crates/v/ruma-api.svg)](https://crates.io/crates/ruma-api) +[![docs.rs page](https://docs.rs/ruma-api/badge.svg)](https://docs.rs/ruma-api/) +![license: MIT](https://img.shields.io/crates/l/ruma-api.svg) + +**ruma-api** contains core types used to define the requests and responses for each endpoint in the various [Matrix](https://matrix.org/) API specifications. +These types can be shared by client and server code for all Matrix APIs. + +## Minimum Rust version + +ruma-api requires Rust 1.40.0 or later. + +## Documentation + +ruma-api has [comprehensive documentation](https://docs.rs/ruma-api) available on docs.rs. diff --git a/ruma-api/ruma-api-macros/CHANGELOG.md b/ruma-api/ruma-api-macros/CHANGELOG.md new file mode 100644 index 00000000..6d0e534f --- /dev/null +++ b/ruma-api/ruma-api-macros/CHANGELOG.md @@ -0,0 +1,172 @@ +Since version 0.15.1 of ruma-api, ruma-api-macros is versioned in lockstep with ruma-api. Since +ruma-api-macros cannot be used independently anyway, it no longer maintains a separate change log or +its own version. Instead, refer to ruma-api's change log for changes in versions 0.15.1 and above. + +# 0.12.0 + +Breaking changes: + +* Update code generation to match the changes in ruma-api 0.15.0 + +# 0.11.0 + +Breaking changes: + +* Use `TryFrom<&str>` instead of serde_json for path segment deserialization + +# 0.10.1 + +Improvements: + +* Derive `Debug` for `Incoming` types + +# 0.10.0 + +Breaking changes: + +* Update code generation to match the changes in ruma-api 0.13.0 + +# 0.9.1 + +Improvements: + +* Add `#[ruma_api(raw_body)]` attribute to `ruma_api!`'s grammar + * This attribute is used to bypass (de)serialization for endpoints where the HTTP request / + response is arbitrary data rather than some JSON structure + +# 0.9.0 + +Breaking changes: + +* Updated code generation to match the changes in ruma-api 0.12.0 + +New features: + +* Added a derive macro for the new `Outgoing` trait from ruma-api + +# 0.8.2 + +Bug fixes: + +* Fix handling of `request` / `response` blocks containing fields with serde attributes ([#31][]) + +[#31]: https://github.com/ruma/ruma-api/pull/31 + +# 0.8.1 + +Improvements: + +* Add spans to almost every error that can come up in `ruma_api!` +* Add a new field kind: `#[ruma_api(query_map)]` ([#30][]) + * This allows endpoints that have a dynamic set of query parameters to be implemented + * For details see the documentation of `ruma_api!` +* Add more sanity checks + * No multiple `#[ruma_api(body)]` fields in one request / response definition + * No multiple field kind declarations `#[ruma_api(body|query|path)]` on one field + * No (newtype) body fields in GET endpoints +* Lots of refactoring of the internals + +[#30]: https://github.com/ruma/ruma-api/pull/30 + +# 0.7.1 + +Bug fixes: + +* Removed unnecessary dependency on ruma-api 0.9.0. + +# 0.7.0 + +Breaking changes: + +* Updated to ruma-api 0.10.0. + +Improvements: + +* ruma-api now re-exports the `ruma_api` macro from ruma-api-macros. Downstream crates no longer need to depend on ruma-api-macros directly. +* The code generated by the `ruma_api` macro now refers to external dependencies via re-exports in ruma-api, so it is no longer necessary to add them to the dependencies of downstream crates directly. +* The ruma-api and ruma-api-macros repositories have been merged into one Cargo workspace for easier dependency management and development. + +# 0.6.0 + +Breaking changes: + +* Updated to ruma-api 0.9.0. + +# 0.5.0 + +Breaking changes: + +* Updated to ruma-api 0.8.0. + +Improvements: + +* Generated documentation now includes the names and descriptions of API endpoints. +* Remove unidiomatic use of `Tokens::append_all` from the `quote` crate. +* ruma-api-macros now uses clippy to improve code quality. + +# 0.4.0 + +Improvements: + +* ruma-api-macros now runs on stable Rust, requiring version 1.34 or higher. +* Updated all dependencies for upstream improvements. + +# 0.3.1 + +Improved: + +* Code updated to use Rust 2018 idioms. + +Bug fixes: + +* The crate will now compile when serde's `derive` feature is enabled. + +# 0.3.0 + +Breaking changes: + +* The procedural macro now uses hyper's `Body` type instead of `Vec`. This may prove to be a temporary change as ideally we want ruma-api-macros to be agnostic to HTTP client library. + +Improvements: + +* Updated to the latest versions of all dependencies. +* Improved error reporting for the procedural macro. +* Conversions between this crate's request and response types and the http crate's request and response types are now bidirectional. +* Request and response types now implement `Clone`. + +# 0.2.2 + +Improvements: + +* Updated to proc-macro2 0.4 for compatibility with the latest nightly Rust. + +Bug fixes: + +* Attributes that don't affect the macro are now ignored instead of causing a panic. +* Added missing commas in request query struct initialization that was causing a syntax error. +* Fixed stripping of serde attributes that was causing them to leak through and trigger a custom attribute error. +* Fixed creation of requests with an empty body that were not correctly using a `Vec` as the body type. + +# 0.2.1 + +Version 0.2.1 was yanked from crates.io due to a dependency issue. Changes since version 0.2.0 are in the release notes for version 0.2.2. + +# 0.2.0 + +Breaking changes: + +* The dependency on the `hyper` crate has been removed. The macro now uses types from the `http` crate. The macro is also compatible with the forthcoming version 0.12 of `hyper`. +* The `method` field in the `metadata` block is now written as the name of an associated constant from `http::Method`, e.g. `GET`. +* HTTP headers are now specified in request and response blocks using `String` as the type, and the name of the constant of the header from `http::header` in the field's attributes. For example: + ``` rust + #[ruma_api(header = "CONTENT_TYPE")] + pub content_type: String + ``` + +Improvements: + +* The macro is built using version 0.13 of the `syn` crate. + +# 0.1.0 + +Initial release. diff --git a/ruma-api/ruma-api-macros/Cargo.toml b/ruma-api/ruma-api-macros/Cargo.toml new file mode 100644 index 00000000..8a2f2c70 --- /dev/null +++ b/ruma-api/ruma-api-macros/Cargo.toml @@ -0,0 +1,24 @@ +[package] +authors = [ + "Jimmy Cuadra ", + "Jonas Platte ", +] +categories = ["api-bindings", "web-programming"] +description = "A procedural macro for generating ruma-api Endpoints." +documentation = "https://docs.rs/ruma-api-macros" +homepage = "https://github.com/ruma/ruma-api" +keywords = ["matrix", "chat", "messaging", "ruma"] +license = "MIT" +name = "ruma-api-macros" +readme = "README.md" +repository = "https://github.com/ruma/ruma-api" +version = "0.16.1" +edition = "2018" + +[dependencies] +proc-macro2 = "1.0.12" +quote = "1.0.5" +syn = { version = "1.0.21", features = ["full", "extra-traits"] } + +[lib] +proc-macro = true diff --git a/ruma-api/ruma-api-macros/README.md b/ruma-api/ruma-api-macros/README.md new file mode 100644 index 00000000..88747d28 --- /dev/null +++ b/ruma-api/ruma-api-macros/README.md @@ -0,0 +1,64 @@ +# ruma-api-macros + +**ruma-api-macros** provides a procedural macro for easily generating [ruma-api](https://github.com/ruma/ruma-api)-compatible API endpoints. +You define the endpoint's metadata, request fields, and response fields, and the macro generates all the necessary types and implements all the necessary traits. + +## Usage + +This crate is not meant to be used directly; instead, you can use it through the re-exports in ruma-api. + +Here is an example that shows most of the macro's functionality: + +```rust +pub mod some_endpoint { + use ruma_api::ruma_api; + + ruma_api! { + metadata { + description: "Does something.", + method: GET, // An `http::Method` constant. No imports required. + name: "some_endpoint", + path: "/_matrix/some/endpoint/:baz", // Variable path components start with a colon. + rate_limited: false, + requires_authentication: false, + } + + request { + // With no attribute on the field, it will be put into the body of the request. + pub foo: String, + + // This value will be put into the "Content-Type" HTTP header. + #[ruma_api(header = CONTENT_TYPE)] + pub content_type: String + + // This value will be put into the query string of the request's URL. + #[ruma_api(query)] + pub bar: String, + + // This value will be inserted into the request's URL in place of the + // ":baz" path component. + #[ruma_api(path)] + pub baz: String, + } + + response { + // This value will be extracted from the "Content-Type" HTTP header. + #[ruma_api(header = CONTENT_TYPE)] + pub content_type: String + + // With no attribute on the field, it will be extracted from the body of the response. + pub value: String, + } + } +} +``` + +## Documentation + +Please refer to the documentation of the `ruma_api!` re-export in [ruma-api][]. + +[ruma-api]: https://docs.rs/ruma-api + +## License + +[MIT](http://opensource.org/licenses/MIT) diff --git a/ruma-api/ruma-api-macros/src/api.rs b/ruma-api/ruma-api-macros/src/api.rs new file mode 100644 index 00000000..e4a3a381 --- /dev/null +++ b/ruma-api/ruma-api-macros/src/api.rs @@ -0,0 +1,635 @@ +//! Details of the `ruma_api` procedural macro. + +use std::convert::{TryFrom, TryInto as _}; + +use proc_macro2::{Span, TokenStream}; +use quote::{quote, ToTokens}; +use syn::{ + braced, + parse::{Parse, ParseStream}, + Field, FieldValue, Ident, Token, Type, +}; + +mod attribute; +mod metadata; +mod request; +mod response; + +use self::{metadata::Metadata, request::Request, response::Response}; + +/// Removes `serde` attributes from struct fields. +pub fn strip_serde_attrs(field: &Field) -> Field { + let mut field = field.clone(); + field.attrs.retain(|attr| !attr.path.is_ident("serde")); + field +} + +/// The result of processing the `ruma_api` macro, ready for output back to source code. +pub struct Api { + /// The `metadata` section of the macro. + metadata: Metadata, + /// The `request` section of the macro. + request: Request, + /// The `response` section of the macro. + response: Response, + /// The `error` section of the macro. + error: Type, +} + +impl TryFrom for Api { + type Error = syn::Error; + + fn try_from(raw_api: RawApi) -> syn::Result { + let res = Self { + metadata: raw_api.metadata.try_into()?, + request: raw_api.request.try_into()?, + response: raw_api.response.try_into()?, + error: raw_api + .error + .map_or(syn::parse_str::("ruma_api::error::Void").unwrap(), |err| err.ty), + }; + + let newtype_body_field = res.request.newtype_body_field(); + if res.metadata.method == "GET" + && (res.request.has_body_fields() || newtype_body_field.is_some()) + { + let mut combined_error: Option = None; + let mut add_error = |field| { + let error = syn::Error::new_spanned(field, "GET endpoints can't have body fields"); + if let Some(combined_error_ref) = &mut combined_error { + combined_error_ref.combine(error); + } else { + combined_error = Some(error); + } + }; + + for field in res.request.body_fields() { + add_error(field); + } + + if let Some(field) = newtype_body_field { + add_error(field); + } + + Err(combined_error.unwrap()) + } else { + Ok(res) + } + } +} + +impl ToTokens for Api { + fn to_tokens(&self, tokens: &mut TokenStream) { + let description = &self.metadata.description; + let method = &self.metadata.method; + // We don't (currently) use this literal as a literal in the generated code. Instead we just + // put it into doc comments, for which the span information is irrelevant. So we can work + // with only the literal's value from here on. + let name = &self.metadata.name.value(); + let path = &self.metadata.path; + let rate_limited = &self.metadata.rate_limited; + let requires_authentication = &self.metadata.requires_authentication; + + let non_auth_endpoint_impl = if requires_authentication.value { + quote! { + impl ruma_api::NonAuthEndpoint for Request {} + } + } else { + TokenStream::new() + }; + + let request_type = &self.request; + let response_type = &self.response; + + let request_try_from_type = if self.request.uses_wrap_incoming() { + quote!(IncomingRequest) + } else { + quote!(Request) + }; + + let response_try_from_type = if self.response.uses_wrap_incoming() { + quote!(IncomingResponse) + } else { + quote!(Response) + }; + + let extract_request_path = if self.request.has_path_fields() { + quote! { + let path_segments: Vec<&str> = request.uri().path()[1..].split('/').collect(); + } + } else { + TokenStream::new() + }; + + let (request_path_string, parse_request_path) = if self.request.has_path_fields() { + let path_string = path.value(); + + assert!(path_string.starts_with('/'), "path needs to start with '/'"); + assert!( + path_string.chars().filter(|c| *c == ':').count() + == self.request.path_field_count(), + "number of declared path parameters needs to match amount of placeholders in path" + ); + + let format_call = { + let mut format_string = path_string.clone(); + let mut format_args = Vec::new(); + + while let Some(start_of_segment) = format_string.find(':') { + // ':' should only ever appear at the start of a segment + assert_eq!(&format_string[start_of_segment - 1..start_of_segment], "/"); + + let end_of_segment = match format_string[start_of_segment..].find('/') { + Some(rel_pos) => start_of_segment + rel_pos, + None => format_string.len(), + }; + + let path_var = Ident::new( + &format_string[start_of_segment + 1..end_of_segment], + Span::call_site(), + ); + format_args.push(quote! { + ruma_api::exports::percent_encoding::utf8_percent_encode( + &request.#path_var.to_string(), + ruma_api::exports::percent_encoding::NON_ALPHANUMERIC, + ) + }); + format_string.replace_range(start_of_segment..end_of_segment, "{}"); + } + + quote! { + format!(#format_string, #(#format_args),*) + } + }; + + let path_fields = path_string[1..] + .split('/') + .enumerate() + .filter(|(_, s)| s.starts_with(':')) + .map(|(i, segment)| { + let path_var = &segment[1..]; + let path_var_ident = Ident::new(path_var, Span::call_site()); + + quote! { + #path_var_ident: { + use std::ops::Deref as _; + use ruma_api::error::RequestDeserializationError; + + let segment = path_segments.get(#i).unwrap().as_bytes(); + let decoded = match ruma_api::exports::percent_encoding::percent_decode( + segment + ).decode_utf8() { + Ok(x) => x, + Err(err) => { + return Err( + RequestDeserializationError::new(err, request).into() + ); + } + }; + match std::convert::TryFrom::try_from(decoded.deref()) { + Ok(val) => val, + Err(err) => { + return Err( + RequestDeserializationError::new(err, request).into() + ); + } + } + } + } + }); + + (format_call, quote! { #(#path_fields,)* }) + } else { + (quote! { metadata.path.to_owned() }, TokenStream::new()) + }; + + let request_query_string = if let Some(field) = self.request.query_map_field() { + let field_name = field.ident.as_ref().expect("expected field to have identifier"); + let field_type = &field.ty; + + quote!({ + // This function exists so that the compiler will throw an + // error when the type of the field with the query_map + // attribute doesn't implement IntoIterator + // + // This is necessary because the ruma_serde::urlencoded::to_string + // call will result in a runtime error when the type cannot be + // encoded as a list key-value pairs (?key1=value1&key2=value2) + // + // By asserting that it implements the iterator trait, we can + // ensure that it won't fail. + fn assert_trait_impl() + where + T: std::iter::IntoIterator, + {} + assert_trait_impl::<#field_type>(); + + let request_query = RequestQuery(request.#field_name); + format!("?{}", ruma_api::exports::ruma_serde::urlencoded::to_string(request_query)?) + }) + } else if self.request.has_query_fields() { + let request_query_init_fields = self.request.request_query_init_fields(); + + quote!({ + let request_query = RequestQuery { + #request_query_init_fields + }; + + format!("?{}", ruma_api::exports::ruma_serde::urlencoded::to_string(request_query)?) + }) + } else { + quote! { + String::new() + } + }; + + let extract_request_query = if self.request.query_map_field().is_some() { + quote! { + let request_query = match ruma_api::exports::ruma_serde::urlencoded::from_str( + &request.uri().query().unwrap_or("") + ) { + Ok(query) => query, + Err(err) => { + return Err( + ruma_api::error::RequestDeserializationError::new(err, request).into() + ); + } + }; + } + } else if self.request.has_query_fields() { + quote! { + let request_query: RequestQuery = + match ruma_api::exports::ruma_serde::urlencoded::from_str( + &request.uri().query().unwrap_or("") + ) { + Ok(query) => query, + Err(err) => { + return Err( + ruma_api::error::RequestDeserializationError::new(err, request) + .into() + ); + } + }; + } + } else { + TokenStream::new() + }; + + let parse_request_query = if let Some(field) = self.request.query_map_field() { + let field_name = field.ident.as_ref().expect("expected field to have an identifier"); + + quote! { + #field_name: request_query + } + } else { + self.request.request_init_query_fields() + }; + + let add_headers_to_request = if self.request.has_header_fields() { + let add_headers = self.request.add_headers_to_request(); + quote! { + let headers = http_request.headers_mut(); + #add_headers + } + } else { + TokenStream::new() + }; + + let extract_request_headers = if self.request.has_header_fields() { + quote! { + let headers = request.headers(); + } + } else { + TokenStream::new() + }; + + let extract_request_body = + if self.request.has_body_fields() || self.request.newtype_body_field().is_some() { + quote! { + let request_body: RequestBody = + match ruma_api::exports::serde_json::from_slice(request.body().as_slice()) { + Ok(body) => body, + Err(err) => { + return Err( + ruma_api::error::RequestDeserializationError::new(err, request) + .into() + ); + } + }; + } + } else { + TokenStream::new() + }; + + let parse_request_headers = if self.request.has_header_fields() { + self.request.parse_headers_from_request() + } else { + TokenStream::new() + }; + + let request_body = if let Some(field) = self.request.newtype_raw_body_field() { + let field_name = field.ident.as_ref().expect("expected field to have an identifier"); + quote!(request.#field_name) + } else if self.request.has_body_fields() || self.request.newtype_body_field().is_some() { + let request_body_initializers = if let Some(field) = self.request.newtype_body_field() { + let field_name = + field.ident.as_ref().expect("expected field to have an identifier"); + quote! { (request.#field_name) } + } else { + let initializers = self.request.request_body_init_fields(); + quote! { { #initializers } } + }; + + quote! { + { + let request_body = RequestBody #request_body_initializers; + ruma_api::exports::serde_json::to_vec(&request_body)? + } + } + } else { + quote!(Vec::new()) + }; + + let parse_request_body = if let Some(field) = self.request.newtype_body_field() { + let field_name = field.ident.as_ref().expect("expected field to have an identifier"); + quote! { + #field_name: request_body.0, + } + } else if let Some(field) = self.request.newtype_raw_body_field() { + let field_name = field.ident.as_ref().expect("expected field to have an identifier"); + quote! { + #field_name: request.into_body(), + } + } else { + self.request.request_init_body_fields() + }; + + let extract_response_headers = if self.response.has_header_fields() { + quote! { + let mut headers = response.headers().clone(); + } + } else { + TokenStream::new() + }; + + let typed_response_body_decl = if self.response.has_body_fields() + || self.response.newtype_body_field().is_some() + { + quote! { + let response_body: ResponseBody = + match ruma_api::exports::serde_json::from_slice(response.body().as_slice()) { + Ok(body) => body, + Err(err) => { + return Err( + ruma_api::error::ResponseDeserializationError::new(err, response) + .into() + ); + } + }; + } + } else { + TokenStream::new() + }; + + let response_init_fields = self.response.init_fields(); + + let serialize_response_headers = self.response.apply_header_fields(); + + let body = self.response.to_body(); + + let request_doc = format!( + "Data for a request to the `{}` API endpoint.\n\n{}", + name, + description.value() + ); + let response_doc = format!("Data in the response from the `{}` API endpoint.", name); + + let error = &self.error; + + let api = quote! { + use ruma_api::exports::serde::de::Error as _; + use ruma_api::exports::serde::Deserialize as _; + use ruma_api::Endpoint as _; + + use std::convert::TryInto as _; + + #[doc = #request_doc] + #request_type + + impl std::convert::TryFrom>> for #request_try_from_type { + type Error = ruma_api::error::FromHttpRequestError; + + #[allow(unused_variables)] + fn try_from(request: ruma_api::exports::http::Request>) -> Result { + #extract_request_path + #extract_request_query + #extract_request_headers + #extract_request_body + + Ok(Self { + #parse_request_path + #parse_request_query + #parse_request_headers + #parse_request_body + }) + } + } + + impl std::convert::TryFrom for ruma_api::exports::http::Request> { + type Error = ruma_api::error::IntoHttpError; + + #[allow(unused_mut, unused_variables)] + fn try_from(request: Request) -> Result { + let metadata = Request::METADATA; + let path_and_query = #request_path_string + &#request_query_string; + let mut http_request = ruma_api::exports::http::Request::new(#request_body); + + *http_request.method_mut() = ruma_api::exports::http::Method::#method; + *http_request.uri_mut() = ruma_api::exports::http::uri::Builder::new() + .path_and_query(path_and_query.as_str()) + .build() + // The only way this can fail is if the path given in the API definition is + // invalid. It is okay to panic in that case. + .unwrap(); + + { #add_headers_to_request } + + Ok(http_request) + } + } + + #[doc = #response_doc] + #response_type + + impl std::convert::TryFrom for ruma_api::exports::http::Response> { + type Error = ruma_api::error::IntoHttpError; + + #[allow(unused_variables)] + fn try_from(response: Response) -> Result { + let response = ruma_api::exports::http::Response::builder() + .header(ruma_api::exports::http::header::CONTENT_TYPE, "application/json") + #serialize_response_headers + .body(#body) + // Since we require header names to come from the `http::header` module, + // this cannot fail. + .unwrap(); + Ok(response) + } + } + + impl std::convert::TryFrom>> for #response_try_from_type { + type Error = ruma_api::error::FromHttpResponseError<#error>; + + #[allow(unused_variables)] + fn try_from( + response: ruma_api::exports::http::Response>, + ) -> Result { + if response.status().as_u16() < 400 { + #extract_response_headers + + #typed_response_body_decl + + Ok(Self { + #response_init_fields + }) + } else { + match <#error as ruma_api::EndpointError>::try_from_response(response) { + Ok(err) => Err(ruma_api::error::ServerError::Known(err).into()), + Err(response_err) => Err(ruma_api::error::ServerError::Unknown(response_err).into()) + } + } + } + } + + impl ruma_api::Endpoint for Request { + type Response = Response; + type ResponseError = #error; + + /// Metadata for the `#name` endpoint. + const METADATA: ruma_api::Metadata = ruma_api::Metadata { + description: #description, + method: ruma_api::exports::http::Method::#method, + name: #name, + path: #path, + rate_limited: #rate_limited, + requires_authentication: #requires_authentication, + }; + } + + #non_auth_endpoint_impl + }; + + api.to_tokens(tokens); + } +} + +/// Custom keyword macros for syn. +mod kw { + use syn::custom_keyword; + + custom_keyword!(metadata); + custom_keyword!(request); + custom_keyword!(response); + custom_keyword!(error); +} + +/// The entire `ruma_api!` macro structure directly as it appears in the source code.. +pub struct RawApi { + /// The `metadata` section of the macro. + pub metadata: RawMetadata, + /// The `request` section of the macro. + pub request: RawRequest, + /// The `response` section of the macro. + pub response: RawResponse, + /// The `error` section of the macro. + pub error: Option, +} + +impl Parse for RawApi { + fn parse(input: ParseStream<'_>) -> syn::Result { + Ok(Self { + metadata: input.parse()?, + request: input.parse()?, + response: input.parse()?, + error: input.parse().ok(), + }) + } +} + +pub struct RawMetadata { + pub metadata_kw: kw::metadata, + pub field_values: Vec, +} + +impl Parse for RawMetadata { + fn parse(input: ParseStream<'_>) -> syn::Result { + let metadata_kw = input.parse::()?; + let field_values; + braced!(field_values in input); + + Ok(Self { + metadata_kw, + field_values: field_values + .parse_terminated::(FieldValue::parse)? + .into_iter() + .collect(), + }) + } +} + +pub struct RawRequest { + pub request_kw: kw::request, + pub fields: Vec, +} + +impl Parse for RawRequest { + fn parse(input: ParseStream<'_>) -> syn::Result { + let request_kw = input.parse::()?; + let fields; + braced!(fields in input); + + Ok(Self { + request_kw, + fields: fields + .parse_terminated::(Field::parse_named)? + .into_iter() + .collect(), + }) + } +} + +pub struct RawResponse { + pub response_kw: kw::response, + pub fields: Vec, +} + +impl Parse for RawResponse { + fn parse(input: ParseStream<'_>) -> syn::Result { + let response_kw = input.parse::()?; + let fields; + braced!(fields in input); + + Ok(Self { + response_kw, + fields: fields + .parse_terminated::(Field::parse_named)? + .into_iter() + .collect(), + }) + } +} + +pub struct RawErrorType { + pub error_kw: kw::error, + pub ty: Type, +} + +impl Parse for RawErrorType { + fn parse(input: ParseStream<'_>) -> syn::Result { + let error_kw = input.parse::()?; + input.parse::()?; + let ty = input.parse()?; + + Ok(Self { error_kw, ty }) + } +} diff --git a/ruma-api/ruma-api-macros/src/api/attribute.rs b/ruma-api/ruma-api-macros/src/api/attribute.rs new file mode 100644 index 00000000..d958ac13 --- /dev/null +++ b/ruma-api/ruma-api-macros/src/api/attribute.rs @@ -0,0 +1,51 @@ +//! Details of the `#[ruma_api(...)]` attributes. + +use syn::{ + parse::{Parse, ParseStream}, + Ident, Token, +}; + +/// Like syn::MetaNameValue, but expects an identifier as the value. Also, we don't care about the +/// the span of the equals sign, so we don't have the `eq_token` field from syn::MetaNameValue. +pub struct MetaNameValue { + /// The part left of the equals sign + pub name: Ident, + /// The part right of the equals sign + pub value: Ident, +} + +/// Like syn::Meta, but only parses ruma_api attributes +pub enum Meta { + /// A single word, like `query` in `#[ruma_api(query)]` + Word(Ident), + /// A name-value pair, like `header = CONTENT_TYPE` in `#[ruma_api(header = CONTENT_TYPE)]` + NameValue(MetaNameValue), +} + +impl Meta { + /// Check if the given attribute is a ruma_api attribute. If it is, parse it. + /// + /// # Panics + /// + /// Panics if the given attribute is a ruma_api attribute, but fails to parse. + pub fn from_attribute(attr: &syn::Attribute) -> syn::Result> { + if attr.path.is_ident("ruma_api") { + attr.parse_args().map(Some) + } else { + Ok(None) + } + } +} + +impl Parse for Meta { + fn parse(input: ParseStream) -> syn::Result { + let ident = input.parse()?; + + if input.peek(Token![=]) { + let _ = input.parse::(); + Ok(Meta::NameValue(MetaNameValue { name: ident, value: input.parse()? })) + } else { + Ok(Meta::Word(ident)) + } + } +} diff --git a/ruma-api/ruma-api-macros/src/api/metadata.rs b/ruma-api/ruma-api-macros/src/api/metadata.rs new file mode 100644 index 00000000..c52c17f1 --- /dev/null +++ b/ruma-api/ruma-api-macros/src/api/metadata.rs @@ -0,0 +1,98 @@ +//! Details of the `metadata` section of the procedural macro. + +use std::convert::TryFrom; + +use syn::{Expr, ExprLit, ExprPath, Ident, Lit, LitBool, LitStr, Member}; + +use crate::api::RawMetadata; + +/// The result of processing the `metadata` section of the macro. +pub struct Metadata { + /// The description field. + pub description: LitStr, + /// The method field. + pub method: Ident, + /// The name field. + pub name: LitStr, + /// The path field. + pub path: LitStr, + /// The rate_limited field. + pub rate_limited: LitBool, + /// The description field. + pub requires_authentication: LitBool, +} + +impl TryFrom for Metadata { + type Error = syn::Error; + + fn try_from(raw: RawMetadata) -> syn::Result { + let mut description = None; + let mut method = None; + let mut name = None; + let mut path = None; + let mut rate_limited = None; + let mut requires_authentication = None; + + for field_value in raw.field_values { + let identifier = match field_value.member.clone() { + Member::Named(identifier) => identifier, + _ => panic!("expected Member::Named"), + }; + let expr = field_value.expr.clone(); + + match &identifier.to_string()[..] { + "description" => match expr { + Expr::Lit(ExprLit { lit: Lit::Str(literal), .. }) => { + description = Some(literal); + } + _ => return Err(syn::Error::new_spanned(expr, "expected a string literal")), + }, + "method" => match expr { + Expr::Path(ExprPath { ref path, .. }) if path.segments.len() == 1 => { + method = Some(path.segments[0].ident.clone()); + } + _ => return Err(syn::Error::new_spanned(expr, "expected an identifier")), + }, + "name" => match expr { + Expr::Lit(ExprLit { lit: Lit::Str(literal), .. }) => { + name = Some(literal); + } + _ => return Err(syn::Error::new_spanned(expr, "expected a string literal")), + }, + "path" => match expr { + Expr::Lit(ExprLit { lit: Lit::Str(literal), .. }) => { + path = Some(literal); + } + _ => return Err(syn::Error::new_spanned(expr, "expected a string literal")), + }, + "rate_limited" => match expr { + Expr::Lit(ExprLit { lit: Lit::Bool(literal), .. }) => { + rate_limited = Some(literal); + } + _ => return Err(syn::Error::new_spanned(expr, "expected a bool literal")), + }, + "requires_authentication" => match expr { + Expr::Lit(ExprLit { lit: Lit::Bool(literal), .. }) => { + requires_authentication = Some(literal); + } + _ => return Err(syn::Error::new_spanned(expr, "expected a bool literal")), + }, + _ => return Err(syn::Error::new_spanned(field_value, "unexpected field")), + } + } + + let metadata_kw = raw.metadata_kw; + let missing_field = + |name| syn::Error::new_spanned(metadata_kw, format!("missing field `{}`", name)); + + Ok(Self { + description: description.ok_or_else(|| missing_field("description"))?, + method: method.ok_or_else(|| missing_field("method"))?, + name: name.ok_or_else(|| missing_field("name"))?, + path: path.ok_or_else(|| missing_field("path"))?, + rate_limited: rate_limited.ok_or_else(|| missing_field("rate_limited"))?, + requires_authentication: requires_authentication + .ok_or_else(|| missing_field("requires_authentication"))?, + }) + } +} diff --git a/ruma-api/ruma-api-macros/src/api/request.rs b/ruma-api/ruma-api-macros/src/api/request.rs new file mode 100644 index 00000000..408060fc --- /dev/null +++ b/ruma-api/ruma-api-macros/src/api/request.rs @@ -0,0 +1,519 @@ +//! Details of the `request` section of the procedural macro. + +use std::{convert::TryFrom, mem}; + +use proc_macro2::TokenStream; +use quote::{quote, quote_spanned, ToTokens}; +use syn::{spanned::Spanned, Field, Ident}; + +use crate::api::{ + attribute::{Meta, MetaNameValue}, + strip_serde_attrs, RawRequest, +}; + +/// The result of processing the `request` section of the macro. +pub struct Request { + /// The fields of the request. + fields: Vec, +} + +impl Request { + /// Produces code to add necessary HTTP headers to an `http::Request`. + pub fn add_headers_to_request(&self) -> TokenStream { + let append_stmts = self.header_fields().map(|request_field| { + let (field, header_name) = match request_field { + RequestField::Header(field, header_name) => (field, header_name), + _ => unreachable!("expected request field to be header variant"), + }; + + let field_name = &field.ident; + + quote! { + headers.append( + ruma_api::exports::http::header::#header_name, + ruma_api::exports::http::header::HeaderValue::from_str(request.#field_name.as_ref()) + .expect("failed to convert value into HeaderValue"), + ); + } + }); + + quote! { + #(#append_stmts)* + } + } + + /// Produces code to extract fields from the HTTP headers in an `http::Request`. + pub fn parse_headers_from_request(&self) -> TokenStream { + let fields = self.header_fields().map(|request_field| { + let (field, header_name) = match request_field { + RequestField::Header(field, header_name) => (field, header_name), + _ => panic!("expected request field to be header variant"), + }; + + let field_name = &field.ident; + let header_name_string = header_name.to_string(); + + quote! { + #field_name: match headers.get(ruma_api::exports::http::header::#header_name) + .and_then(|v| v.to_str().ok()) { + Some(header) => header.to_owned(), + None => { + return Err( + ruma_api::error::RequestDeserializationError::new( + ruma_api::exports::serde_json::Error::missing_field( + #header_name_string + ), + request, + ) + .into() + ); + } + } + } + }); + + quote! { + #(#fields,)* + } + } + + /// Whether or not this request has any data in the HTTP body. + pub fn has_body_fields(&self) -> bool { + self.fields.iter().any(|field| field.is_body()) + } + + /// Whether or not this request has any data in HTTP headers. + pub fn has_header_fields(&self) -> bool { + self.fields.iter().any(|field| field.is_header()) + } + + /// Whether or not this request has any data in the URL path. + pub fn has_path_fields(&self) -> bool { + self.fields.iter().any(|field| field.is_path()) + } + + /// Whether or not this request has any data in the query string. + pub fn has_query_fields(&self) -> bool { + self.fields.iter().any(|field| field.is_query()) + } + + /// Produces an iterator over all the body fields. + pub fn body_fields(&self) -> impl Iterator { + self.fields.iter().filter_map(|field| field.as_body_field()) + } + + /// Whether any field has a #[wrap_incoming] attribute. + pub fn uses_wrap_incoming(&self) -> bool { + self.fields.iter().any(|f| f.has_wrap_incoming_attr()) + } + + /// Produces an iterator over all the header fields. + pub fn header_fields(&self) -> impl Iterator { + self.fields.iter().filter(|field| field.is_header()) + } + + /// Gets the number of path fields. + pub fn path_field_count(&self) -> usize { + self.fields.iter().filter(|field| field.is_path()).count() + } + + /// Returns the body field. + pub fn newtype_body_field(&self) -> Option<&Field> { + self.fields.iter().find_map(RequestField::as_newtype_body_field) + } + + /// Returns the body field. + pub fn newtype_raw_body_field(&self) -> Option<&Field> { + self.fields.iter().find_map(RequestField::as_newtype_raw_body_field) + } + + /// Returns the query map field. + pub fn query_map_field(&self) -> Option<&Field> { + self.fields.iter().find_map(RequestField::as_query_map_field) + } + + /// Produces code for a struct initializer for body fields on a variable named `request`. + pub fn request_body_init_fields(&self) -> TokenStream { + self.struct_init_fields(RequestFieldKind::Body, quote!(request)) + } + + /// Produces code for a struct initializer for query string fields on a variable named `request`. + pub fn request_query_init_fields(&self) -> TokenStream { + self.struct_init_fields(RequestFieldKind::Query, quote!(request)) + } + + /// Produces code for a struct initializer for body fields on a variable named `request_body`. + pub fn request_init_body_fields(&self) -> TokenStream { + self.struct_init_fields(RequestFieldKind::Body, quote!(request_body)) + } + + /// Produces code for a struct initializer for query string fields on a variable named + /// `request_query`. + pub fn request_init_query_fields(&self) -> TokenStream { + self.struct_init_fields(RequestFieldKind::Query, quote!(request_query)) + } + + /// Produces code for a struct initializer for the given field kind to be accessed through the + /// given variable name. + fn struct_init_fields( + &self, + request_field_kind: RequestFieldKind, + src: TokenStream, + ) -> TokenStream { + let fields = self.fields.iter().filter_map(|f| { + f.field_of_kind(request_field_kind).map(|field| { + let field_name = + field.ident.as_ref().expect("expected field to have an identifier"); + let span = field.span(); + + quote_spanned! {span=> + #field_name: #src.#field_name + } + }) + }); + + quote! { #(#fields,)* } + } +} + +impl TryFrom for Request { + type Error = syn::Error; + + fn try_from(raw: RawRequest) -> syn::Result { + let mut newtype_body_field = None; + let mut query_map_field = None; + + let fields = raw + .fields + .into_iter() + .map(|mut field| { + let mut field_kind = None; + let mut header = None; + + for attr in mem::replace(&mut field.attrs, Vec::new()) { + let meta = match Meta::from_attribute(&attr)? { + Some(m) => m, + None => { + field.attrs.push(attr); + continue; + } + }; + + if field_kind.is_some() { + return Err(syn::Error::new_spanned( + attr, + "There can only be one field kind attribute", + )); + } + + field_kind = Some(match meta { + Meta::Word(ident) => { + match &ident.to_string()[..] { + s @ "body" | s @ "raw_body" => { + if let Some(f) = &newtype_body_field { + let mut error = syn::Error::new_spanned( + field, + "There can only be one newtype body field", + ); + error.combine(syn::Error::new_spanned( + f, + "Previous newtype body field", + )); + return Err(error); + } + + newtype_body_field = Some(field.clone()); + match s { + "body" => RequestFieldKind::NewtypeBody, + "raw_body" => RequestFieldKind::NewtypeRawBody, + _ => unreachable!(), + } + } + "path" => RequestFieldKind::Path, + "query" => RequestFieldKind::Query, + "query_map" => { + if let Some(f) = &query_map_field { + let mut error = syn::Error::new_spanned( + field, + "There can only be one query map field", + ); + error.combine(syn::Error::new_spanned( + f, + "Previous query map field", + )); + return Err(error); + } + + query_map_field = Some(field.clone()); + RequestFieldKind::QueryMap + }, + _ => { + return Err(syn::Error::new_spanned( + ident, + "Invalid #[ruma_api] argument, expected one of `body`, `path`, `query`, `query_map`", + )); + } + } + } + Meta::NameValue(MetaNameValue { name, value }) => { + if name != "header" { + return Err(syn::Error::new_spanned( + name, + "Invalid #[ruma_api] argument with value, expected `header`" + )); + } + + header = Some(value); + RequestFieldKind::Header + } + }); + } + + Ok(RequestField::new( + field_kind.unwrap_or(RequestFieldKind::Body), + field, + header, + )) + }) + .collect::>>()?; + + if newtype_body_field.is_some() && fields.iter().any(|f| f.is_body()) { + // TODO: highlight conflicting fields, + return Err(syn::Error::new_spanned( + raw.request_kw, + "Can't have both a newtype body field and regular body fields", + )); + } + + if query_map_field.is_some() && fields.iter().any(|f| f.is_query()) { + return Err(syn::Error::new_spanned( + // TODO: raw, + raw.request_kw, + "Can't have both a query map field and regular query fields", + )); + } + + Ok(Self { fields }) + } +} + +impl ToTokens for Request { + fn to_tokens(&self, tokens: &mut TokenStream) { + let request_def = if self.fields.is_empty() { + quote!(;) + } else { + let fields = + self.fields.iter().map(|request_field| strip_serde_attrs(request_field.field())); + quote! { { #(#fields),* } } + }; + + let request_body_struct = + if let Some(body_field) = self.fields.iter().find(|f| f.is_newtype_body()) { + let field = Field { ident: None, colon_token: None, ..body_field.field().clone() }; + Some(quote! { (#field); }) + } else if self.has_body_fields() { + let fields = self.fields.iter().filter(|f| f.is_body()); + let fields = fields.map(RequestField::field); + Some(quote! { { #(#fields),* } }) + } else { + None + } + .map(|def| { + quote! { + /// Data in the request body. + #[derive( + Debug, + ruma_api::exports::serde::Deserialize, + ruma_api::exports::serde::Serialize, + )] + struct RequestBody #def + } + }); + + let request_query_struct = if let Some(f) = self.query_map_field() { + let field = Field { ident: None, colon_token: None, ..f.clone() }; + + quote! { + /// Data in the request's query string. + #[derive( + Debug, + ruma_api::exports::serde::Deserialize, + ruma_api::exports::serde::Serialize, + )] + struct RequestQuery(#field); + } + } else if self.has_query_fields() { + let fields = self.fields.iter().filter_map(RequestField::as_query_field); + + quote! { + /// Data in the request's query string. + #[derive( + Debug, + ruma_api::exports::serde::Deserialize, + ruma_api::exports::serde::Serialize, + )] + struct RequestQuery { + #(#fields),* + } + } + } else { + TokenStream::new() + }; + + let request = quote! { + #[derive(Debug, Clone)] + pub struct Request #request_def + + #request_body_struct + #request_query_struct + }; + + request.to_tokens(tokens); + } +} + +/// The types of fields that a request can have. +pub enum RequestField { + /// JSON data in the body of the request. + Body(Field), + /// Data in an HTTP header. + Header(Field, Ident), + /// A specific data type in the body of the request. + NewtypeBody(Field), + /// Arbitrary bytes in the body of the request. + NewtypeRawBody(Field), + /// Data that appears in the URL path. + Path(Field), + /// Data that appears in the query string. + Query(Field), + /// Data that appears in the query string as dynamic key-value pairs. + QueryMap(Field), +} + +impl RequestField { + /// Creates a new `RequestField`. + fn new(kind: RequestFieldKind, field: Field, header: Option) -> Self { + match kind { + RequestFieldKind::Body => RequestField::Body(field), + RequestFieldKind::Header => { + RequestField::Header(field, header.expect("missing header name")) + } + RequestFieldKind::NewtypeBody => RequestField::NewtypeBody(field), + RequestFieldKind::NewtypeRawBody => RequestField::NewtypeRawBody(field), + RequestFieldKind::Path => RequestField::Path(field), + RequestFieldKind::Query => RequestField::Query(field), + RequestFieldKind::QueryMap => RequestField::QueryMap(field), + } + } + + /// Gets the kind of the request field. + fn kind(&self) -> RequestFieldKind { + match self { + RequestField::Body(..) => RequestFieldKind::Body, + RequestField::Header(..) => RequestFieldKind::Header, + RequestField::NewtypeBody(..) => RequestFieldKind::NewtypeBody, + RequestField::NewtypeRawBody(..) => RequestFieldKind::NewtypeRawBody, + RequestField::Path(..) => RequestFieldKind::Path, + RequestField::Query(..) => RequestFieldKind::Query, + RequestField::QueryMap(..) => RequestFieldKind::QueryMap, + } + } + + /// Whether or not this request field is a body kind. + fn is_body(&self) -> bool { + self.kind() == RequestFieldKind::Body + } + + /// Whether or not this request field is a header kind. + fn is_header(&self) -> bool { + self.kind() == RequestFieldKind::Header + } + + /// Whether or not this request field is a newtype body kind. + fn is_newtype_body(&self) -> bool { + self.kind() == RequestFieldKind::NewtypeBody + } + + /// Whether or not this request field is a path kind. + fn is_path(&self) -> bool { + self.kind() == RequestFieldKind::Path + } + + /// Whether or not this request field is a query string kind. + fn is_query(&self) -> bool { + self.kind() == RequestFieldKind::Query + } + + /// Return the contained field if this request field is a body kind. + fn as_body_field(&self) -> Option<&Field> { + self.field_of_kind(RequestFieldKind::Body) + } + + /// Return the contained field if this request field is a body kind. + fn as_newtype_body_field(&self) -> Option<&Field> { + self.field_of_kind(RequestFieldKind::NewtypeBody) + } + + /// Return the contained field if this request field is a raw body kind. + fn as_newtype_raw_body_field(&self) -> Option<&Field> { + self.field_of_kind(RequestFieldKind::NewtypeRawBody) + } + + /// Return the contained field if this request field is a query kind. + fn as_query_field(&self) -> Option<&Field> { + self.field_of_kind(RequestFieldKind::Query) + } + + /// Return the contained field if this request field is a query map kind. + fn as_query_map_field(&self) -> Option<&Field> { + self.field_of_kind(RequestFieldKind::QueryMap) + } + + /// Gets the inner `Field` value. + fn field(&self) -> &Field { + match self { + RequestField::Body(field) + | RequestField::Header(field, _) + | RequestField::NewtypeBody(field) + | RequestField::NewtypeRawBody(field) + | RequestField::Path(field) + | RequestField::Query(field) + | RequestField::QueryMap(field) => field, + } + } + + /// Gets the inner `Field` value if it's of the provided kind. + fn field_of_kind(&self, kind: RequestFieldKind) -> Option<&Field> { + if self.kind() == kind { + Some(self.field()) + } else { + None + } + } + + /// Whether or not the request field has a #[wrap_incoming] attribute. + fn has_wrap_incoming_attr(&self) -> bool { + self.field().attrs.iter().any(|attr| { + attr.path.segments.len() == 1 && attr.path.segments[0].ident == "wrap_incoming" + }) + } +} + +/// The types of fields that a request can have, without their values. +#[derive(Clone, Copy, PartialEq, Eq)] +enum RequestFieldKind { + /// See the similarly named variant of `RequestField`. + Body, + /// See the similarly named variant of `RequestField`. + Header, + /// See the similarly named variant of `RequestField`. + NewtypeBody, + /// See the similarly named variant of `RequestField`. + NewtypeRawBody, + /// See the similarly named variant of `RequestField`. + Path, + /// See the similarly named variant of `RequestField`. + Query, + /// See the similarly named variant of `RequestField`. + QueryMap, +} diff --git a/ruma-api/ruma-api-macros/src/api/response.rs b/ruma-api/ruma-api-macros/src/api/response.rs new file mode 100644 index 00000000..2bdef543 --- /dev/null +++ b/ruma-api/ruma-api-macros/src/api/response.rs @@ -0,0 +1,361 @@ +//! Details of the `response` section of the procedural macro. + +use std::{convert::TryFrom, mem}; + +use proc_macro2::TokenStream; +use quote::{quote, quote_spanned, ToTokens}; +use syn::{spanned::Spanned, Field, Ident}; + +use crate::api::{ + attribute::{Meta, MetaNameValue}, + strip_serde_attrs, RawResponse, +}; + +/// The result of processing the `response` section of the macro. +pub struct Response { + /// The fields of the response. + fields: Vec, +} + +impl Response { + /// Whether or not this response has any data in the HTTP body. + pub fn has_body_fields(&self) -> bool { + self.fields.iter().any(|field| field.is_body()) + } + + /// Whether or not this response has any data in HTTP headers. + pub fn has_header_fields(&self) -> bool { + self.fields.iter().any(|field| field.is_header()) + } + + /// Whether any field has a #[wrap_incoming] attribute. + pub fn uses_wrap_incoming(&self) -> bool { + self.fields.iter().any(|f| f.has_wrap_incoming_attr()) + } + + /// Produces code for a response struct initializer. + pub fn init_fields(&self) -> TokenStream { + let fields = self.fields.iter().map(|response_field| { + let field = response_field.field(); + let field_name = field.ident.as_ref().expect("expected field to have an identifier"); + let span = field.span(); + + match response_field { + ResponseField::Body(_) => { + quote_spanned! {span=> + #field_name: response_body.#field_name + } + } + ResponseField::Header(_, header_name) => { + quote_spanned! {span=> + #field_name: headers.remove(ruma_api::exports::http::header::#header_name) + .expect("response missing expected header") + .to_str() + .expect("failed to convert HeaderValue to str") + .to_owned() + } + } + ResponseField::NewtypeBody(_) => { + quote_spanned! {span=> + #field_name: response_body.0 + } + } + ResponseField::NewtypeRawBody(_) => { + quote_spanned! {span=> + #field_name: response.into_body() + } + } + } + }); + + quote! { + #(#fields,)* + } + } + + /// Produces code to add necessary HTTP headers to an `http::Response`. + pub fn apply_header_fields(&self) -> TokenStream { + let header_calls = self.fields.iter().filter_map(|response_field| { + if let ResponseField::Header(ref field, ref header_name) = *response_field { + let field_name = + field.ident.as_ref().expect("expected field to have an identifier"); + let span = field.span(); + + Some(quote_spanned! {span=> + .header(ruma_api::exports::http::header::#header_name, response.#field_name) + }) + } else { + None + } + }); + + quote! { #(#header_calls)* } + } + + /// Produces code to initialize the struct that will be used to create the response body. + pub fn to_body(&self) -> TokenStream { + if let Some(field) = self.newtype_raw_body_field() { + let field_name = field.ident.as_ref().expect("expected field to have an identifier"); + let span = field.span(); + return quote_spanned!(span=> response.#field_name); + } + + let body = if let Some(field) = self.newtype_body_field() { + let field_name = field.ident.as_ref().expect("expected field to have an identifier"); + let span = field.span(); + quote_spanned!(span=> response.#field_name) + } else { + let fields = self.fields.iter().filter_map(|response_field| { + if let ResponseField::Body(ref field) = *response_field { + let field_name = + field.ident.as_ref().expect("expected field to have an identifier"); + let span = field.span(); + + Some(quote_spanned! {span=> + #field_name: response.#field_name + }) + } else { + None + } + }); + + quote! { + ResponseBody { #(#fields),* } + } + }; + + quote!(ruma_api::exports::serde_json::to_vec(&#body)?) + } + + /// Gets the newtype body field, if this response has one. + pub fn newtype_body_field(&self) -> Option<&Field> { + self.fields.iter().find_map(ResponseField::as_newtype_body_field) + } + + /// Gets the newtype raw body field, if this response has one. + pub fn newtype_raw_body_field(&self) -> Option<&Field> { + self.fields.iter().find_map(ResponseField::as_newtype_raw_body_field) + } +} + +impl TryFrom for Response { + type Error = syn::Error; + + fn try_from(raw: RawResponse) -> syn::Result { + let mut newtype_body_field = None; + + let fields = raw + .fields + .into_iter() + .map(|mut field| { + let mut field_kind = None; + let mut header = None; + + for attr in mem::replace(&mut field.attrs, Vec::new()) { + let meta = match Meta::from_attribute(&attr)? { + Some(m) => m, + None => { + field.attrs.push(attr); + continue; + } + }; + + if field_kind.is_some() { + return Err(syn::Error::new_spanned( + attr, + "There can only be one field kind attribute", + )); + } + + field_kind = Some(match meta { + Meta::Word(ident) => match &ident.to_string()[..] { + s @ "body" | s @ "raw_body" => { + if let Some(f) = &newtype_body_field { + let mut error = syn::Error::new_spanned( + field, + "There can only be one newtype body field", + ); + error.combine(syn::Error::new_spanned( + f, + "Previous newtype body field", + )); + return Err(error); + } + + newtype_body_field = Some(field.clone()); + match s { + "body" => ResponseFieldKind::NewtypeBody, + "raw_body" => ResponseFieldKind::NewtypeRawBody, + _ => unreachable!(), + } + } + _ => { + return Err(syn::Error::new_spanned( + ident, + "Invalid #[ruma_api] argument with value, expected `body`", + )); + } + }, + Meta::NameValue(MetaNameValue { name, value }) => { + if name != "header" { + return Err(syn::Error::new_spanned( + name, + "Invalid #[ruma_api] argument with value, expected `header`", + )); + } + + header = Some(value); + ResponseFieldKind::Header + } + }); + } + + Ok(match field_kind.unwrap_or(ResponseFieldKind::Body) { + ResponseFieldKind::Body => ResponseField::Body(field), + ResponseFieldKind::Header => { + ResponseField::Header(field, header.expect("missing header name")) + } + ResponseFieldKind::NewtypeBody => ResponseField::NewtypeBody(field), + ResponseFieldKind::NewtypeRawBody => ResponseField::NewtypeRawBody(field), + }) + }) + .collect::>>()?; + + if newtype_body_field.is_some() && fields.iter().any(|f| f.is_body()) { + // TODO: highlight conflicting fields, + return Err(syn::Error::new_spanned( + raw.response_kw, + "Can't have both a newtype body field and regular body fields", + )); + } + + Ok(Self { fields }) + } +} + +impl ToTokens for Response { + fn to_tokens(&self, tokens: &mut TokenStream) { + let response_def = if self.fields.is_empty() { + quote!(;) + } else { + let fields = + self.fields.iter().map(|response_field| strip_serde_attrs(response_field.field())); + + quote! { { #(#fields),* } } + }; + + let def = if let Some(body_field) = self.fields.iter().find(|f| f.is_newtype_body()) { + let field = Field { ident: None, colon_token: None, ..body_field.field().clone() }; + quote! { (#field); } + } else if self.has_body_fields() { + let fields = self.fields.iter().filter_map(|f| f.as_body_field()); + quote!({ #(#fields),* }) + } else { + quote!({}) + }; + + let response_body_struct = quote! { + /// Data in the response body. + #[derive( + Debug, + ruma_api::exports::serde::Deserialize, + ruma_api::exports::serde::Serialize, + )] + struct ResponseBody #def + }; + + let response = quote! { + #[derive(Debug, Clone)] + pub struct Response #response_def + + #response_body_struct + }; + + response.to_tokens(tokens); + } +} + +/// The types of fields that a response can have. +pub enum ResponseField { + /// JSON data in the body of the response. + Body(Field), + /// Data in an HTTP header. + Header(Field, Ident), + /// A specific data type in the body of the response. + NewtypeBody(Field), + /// Arbitrary bytes in the body of the response. + NewtypeRawBody(Field), +} + +impl ResponseField { + /// Gets the inner `Field` value. + fn field(&self) -> &Field { + match self { + ResponseField::Body(field) + | ResponseField::Header(field, _) + | ResponseField::NewtypeBody(field) + | ResponseField::NewtypeRawBody(field) => field, + } + } + + /// Whether or not this response field is a body kind. + fn is_body(&self) -> bool { + self.as_body_field().is_some() + } + + /// Whether or not this response field is a header kind. + fn is_header(&self) -> bool { + match self { + ResponseField::Header(..) => true, + _ => false, + } + } + + /// Whether or not this response field is a newtype body kind. + fn is_newtype_body(&self) -> bool { + self.as_newtype_body_field().is_some() + } + + /// Return the contained field if this response field is a body kind. + fn as_body_field(&self) -> Option<&Field> { + match self { + ResponseField::Body(field) => Some(field), + _ => None, + } + } + + /// Return the contained field if this response field is a newtype body kind. + fn as_newtype_body_field(&self) -> Option<&Field> { + match self { + ResponseField::NewtypeBody(field) => Some(field), + _ => None, + } + } + + /// Return the contained field if this response field is a newtype raw body kind. + fn as_newtype_raw_body_field(&self) -> Option<&Field> { + match self { + ResponseField::NewtypeRawBody(field) => Some(field), + _ => None, + } + } + + /// Whether or not the reponse field has a #[wrap_incoming] attribute. + fn has_wrap_incoming_attr(&self) -> bool { + self.field().attrs.iter().any(|attr| { + attr.path.segments.len() == 1 && attr.path.segments[0].ident == "wrap_incoming" + }) + } +} + +/// The types of fields that a response can have, without their values. +enum ResponseFieldKind { + /// See the similarly named variant of `ResponseField`. + Body, + /// See the similarly named variant of `ResponseField`. + Header, + /// See the similarly named variant of `ResponseField`. + NewtypeBody, + /// See the similarly named variant of `ResponseField`. + NewtypeRawBody, +} diff --git a/ruma-api/ruma-api-macros/src/lib.rs b/ruma-api/ruma-api-macros/src/lib.rs new file mode 100644 index 00000000..b8e39bde --- /dev/null +++ b/ruma-api/ruma-api-macros/src/lib.rs @@ -0,0 +1,30 @@ +//! Crate ruma-api-macros provides a procedural macro for easily generating +//! [ruma-api](https://github.com/ruma/ruma-api)-compatible endpoints. +//! +//! This crate should never be used directly; instead, use it through the +//! re-exports in ruma-api. Also note that for technical reasons, the +//! `ruma_api!` macro is only documented in ruma-api, not here. + +#![allow(clippy::cognitive_complexity)] +#![recursion_limit = "256"] + +extern crate proc_macro; + +use std::convert::TryFrom as _; + +use proc_macro::TokenStream; +use quote::ToTokens; +use syn::parse_macro_input; + +use self::api::{Api, RawApi}; + +mod api; + +#[proc_macro] +pub fn ruma_api(input: TokenStream) -> TokenStream { + let raw_api = parse_macro_input!(input as RawApi); + match Api::try_from(raw_api) { + Ok(api) => api.into_token_stream().into(), + Err(err) => err.to_compile_error().into(), + } +} diff --git a/ruma-api/src/error.rs b/ruma-api/src/error.rs new file mode 100644 index 00000000..31233b62 --- /dev/null +++ b/ruma-api/src/error.rs @@ -0,0 +1,267 @@ +//! This module contains types for all kinds of errors that can occur when +//! converting between http requests / responses and ruma's representation of +//! matrix API requests / responses. + +use std::fmt::{self, Display, Formatter}; + +// FIXME when `!` becomes stable use it +/// Default `ResponseError` for `ruma_api!` macro +#[derive(Clone, Copy, Debug)] +pub struct Void; + +impl crate::EndpointError for Void { + fn try_from_response( + response: http::Response>, + ) -> Result { + Err(ResponseDeserializationError::from_response(response)) + } +} +/// An error when converting one of ruma's endpoint-specific request or response +/// types to the corresponding http type. +#[derive(Debug)] +pub struct IntoHttpError(SerializationError); + +#[doc(hidden)] +impl From for IntoHttpError { + fn from(err: serde_json::Error) -> Self { + Self(SerializationError::Json(err)) + } +} + +#[doc(hidden)] +impl From for IntoHttpError { + fn from(err: ruma_serde::urlencoded::ser::Error) -> Self { + Self(SerializationError::Query(err)) + } +} + +impl Display for IntoHttpError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match &self.0 { + SerializationError::Json(err) => write!(f, "JSON serialization failed: {}", err), + SerializationError::Query(err) => { + write!(f, "Query parameter serialization failed: {}", err) + } + } + } +} + +impl std::error::Error for IntoHttpError {} + +/// An error when converting a http request to one of ruma's endpoint-specific +/// request types. +#[derive(Debug)] +#[non_exhaustive] +pub enum FromHttpRequestError { + /// Deserialization failed + Deserialization(RequestDeserializationError), +} + +impl Display for FromHttpRequestError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Deserialization(err) => write!(f, "deserialization failed: {}", err), + } + } +} + +impl From for FromHttpRequestError { + fn from(err: RequestDeserializationError) -> Self { + Self::Deserialization(err) + } +} + +impl std::error::Error for FromHttpRequestError {} + +/// An error that occurred when trying to deserialize a request. +#[derive(Debug)] +pub struct RequestDeserializationError { + inner: DeserializationError, + http_request: http::Request>, +} + +impl RequestDeserializationError { + /// This method is public so it is accessible from `ruma_api!` generated + /// code. It is not considered part of ruma-api's public API. + #[doc(hidden)] + pub fn new( + inner: impl Into, + http_request: http::Request>, + ) -> Self { + Self { inner: inner.into(), http_request } + } +} + +impl Display for RequestDeserializationError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(&self.inner, f) + } +} + +impl std::error::Error for RequestDeserializationError {} + +/// An error when converting a http response to one of ruma's endpoint-specific +/// response types. +#[derive(Debug)] +#[non_exhaustive] +pub enum FromHttpResponseError { + /// Deserialization failed + Deserialization(ResponseDeserializationError), + /// The server returned a non-success status + Http(ServerError), +} + +impl Display for FromHttpResponseError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Deserialization(err) => write!(f, "deserialization failed: {}", err), + Self::Http(err) => write!(f, "the server returned an error: {}", err), + } + } +} + +impl From> for FromHttpResponseError { + fn from(err: ServerError) -> Self { + Self::Http(err) + } +} + +impl From for FromHttpResponseError { + fn from(err: ResponseDeserializationError) -> Self { + Self::Deserialization(err) + } +} + +/// An error that occurred when trying to deserialize a response. +#[derive(Debug)] +pub struct ResponseDeserializationError { + inner: Option, + http_response: http::Response>, +} + +impl ResponseDeserializationError { + /// This method is public so it is accessible from `ruma_api!` generated + /// code. It is not considered part of ruma-api's public API. + #[doc(hidden)] + pub fn new( + inner: impl Into, + http_response: http::Response>, + ) -> Self { + Self { inner: Some(inner.into()), http_response } + } + + /// This method is public so it is accessible from `ruma_api!` generated + /// code. It is not considered part of ruma-api's public API. + /// Creates an Error from a `http::Response`. + #[doc(hidden)] + pub fn from_response(http_response: http::Response>) -> Self { + Self { http_response, inner: None } + } +} + +impl Display for ResponseDeserializationError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + if let Some(ref inner) = self.inner { + Display::fmt(inner, f) + } else { + Display::fmt("deserialization error, no error specified", f) + } + } +} + +impl std::error::Error for ResponseDeserializationError {} + +/// An error was reported by the server (HTTP status code 4xx or 5xx) +#[derive(Debug)] +pub enum ServerError { + /// An error that is expected to happen under certain circumstances and + /// that has a well-defined structure + Known(E), + /// An error of unexpected type of structure + Unknown(ResponseDeserializationError), +} + +impl Display for ServerError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + ServerError::Known(e) => Display::fmt(e, f), + ServerError::Unknown(res_err) => Display::fmt(res_err, f), + } + } +} + +impl std::error::Error for ServerError {} + +#[derive(Debug)] +enum SerializationError { + Json(serde_json::Error), + Query(ruma_serde::urlencoded::ser::Error), +} + +/// This type is public so it is accessible from `ruma_api!` generated code. +/// It is not considered part of ruma-api's public API. +#[doc(hidden)] +#[derive(Debug)] +pub enum DeserializationError { + Utf8(std::str::Utf8Error), + Json(serde_json::Error), + Query(ruma_serde::urlencoded::de::Error), + Ident(ruma_identifiers::Error), + // String <> Enum conversion failed. This can currently only happen in path + // segment deserialization + Strum(strum::ParseError), +} + +impl Display for DeserializationError { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + DeserializationError::Utf8(err) => Display::fmt(err, f), + DeserializationError::Json(err) => Display::fmt(err, f), + DeserializationError::Query(err) => Display::fmt(err, f), + DeserializationError::Ident(err) => Display::fmt(err, f), + DeserializationError::Strum(err) => Display::fmt(err, f), + } + } +} + +#[doc(hidden)] +impl From for DeserializationError { + fn from(err: std::str::Utf8Error) -> Self { + Self::Utf8(err) + } +} + +#[doc(hidden)] +impl From for DeserializationError { + fn from(err: serde_json::Error) -> Self { + Self::Json(err) + } +} + +#[doc(hidden)] +impl From for DeserializationError { + fn from(err: ruma_serde::urlencoded::de::Error) -> Self { + Self::Query(err) + } +} + +#[doc(hidden)] +impl From for DeserializationError { + fn from(err: ruma_identifiers::Error) -> Self { + Self::Ident(err) + } +} + +#[doc(hidden)] +impl From for DeserializationError { + fn from(err: strum::ParseError) -> Self { + Self::Strum(err) + } +} + +#[doc(hidden)] +impl From for DeserializationError { + fn from(err: std::convert::Infallible) -> Self { + match err {} + } +} diff --git a/ruma-api/src/lib.rs b/ruma-api/src/lib.rs new file mode 100644 index 00000000..4239732c --- /dev/null +++ b/ruma-api/src/lib.rs @@ -0,0 +1,401 @@ +//! Crate `ruma_api` contains core types used to define the requests and responses for each endpoint +//! in the various [Matrix](https://matrix.org) API specifications. +//! These types can be shared by client and server code for all Matrix APIs. +//! +//! When implementing a new Matrix API, each endpoint has a request type which implements +//! `Endpoint`, and a response type connected via an associated type. +//! +//! An implementation of `Endpoint` contains all the information about the HTTP method, the path and +//! input parameters for requests, and the structure of a successful response. +//! Such types can then be used by client code to make requests, and by server code to fulfill +//! those requests. +#![doc(html_favicon_url = "https://www.ruma.io/favicon.ico")] +#![warn(rust_2018_idioms)] +#![deny(missing_copy_implementations, missing_debug_implementations, missing_docs)] + +use std::convert::{TryFrom, TryInto}; + +use http::Method; + +/// Generates a `ruma_api::Endpoint` from a concise definition. +/// +/// The macro expects the following structure as input: +/// +/// ```text +/// ruma_api! { +/// metadata { +/// description: &'static str, +/// method: http::Method, +/// name: &'static str, +/// path: &'static str, +/// rate_limited: bool, +/// requires_authentication: bool, +/// } +/// +/// request { +/// // Struct fields for each piece of data required +/// // to make a request to this API endpoint. +/// } +/// +/// response { +/// // Struct fields for each piece of data expected +/// // in the response from this API endpoint. +/// } +/// } +/// ``` +/// +/// This will generate a `ruma_api::Metadata` value to be used for the `ruma_api::Endpoint`'s +/// associated constant, single `Request` and `Response` structs, and the necessary trait +/// implementations to convert the request into a `http::Request` and to create a response from a +/// `http::Response` and vice versa. +/// +/// The details of each of the three sections of the macros are documented below. +/// +/// ## Metadata +/// +/// * `description`: A short description of what the endpoint does. +/// * `method`: The HTTP method used for requests to the endpoint. +/// It's not necessary to import `http::Method`'s associated constants. Just write +/// the value as if it was imported, e.g. `GET`. +/// * `name`: A unique name for the endpoint. +/// Generally this will be the same as the containing module. +/// * `path`: The path component of the URL for the endpoint, e.g. "/foo/bar". +/// Components of the path that are parameterized can indicate a varible by using a Rust +/// identifier prefixed with a colon, e.g. `/foo/:some_parameter`. +/// A corresponding query string parameter will be expected in the request struct (see below +/// for details). +/// * `rate_limited`: Whether or not the endpoint enforces rate limiting on requests. +/// * `requires_authentication`: Whether or not the endpoint requires a valid access token. +/// +/// ## Request +/// +/// The request block contains normal struct field definitions. +/// Doc comments and attributes are allowed as normal. +/// There are also a few special attributes available to control how the struct is converted into a +/// `http::Request`: +/// +/// * `#[ruma_api(header = HEADER_NAME)]`: Fields with this attribute will be treated as HTTP +/// headers on the request. +/// The value must implement `AsRef`. +/// Generally this is a `String`. +/// The attribute value shown above as `HEADER_NAME` must be a header name constant from +/// `http::header`, e.g. `CONTENT_TYPE`. +/// * `#[ruma_api(path)]`: Fields with this attribute will be inserted into the matching path +/// component of the request URL. +/// * `#[ruma_api(query)]`: Fields with this attribute will be inserting into the URL's query +/// string. +/// * `#[ruma_api(query_map)]`: Instead of individual query fields, one query_map field, of any +/// type that implements `IntoIterator` (e.g. +/// `HashMap`, can be used for cases where an endpoint supports arbitrary query +/// parameters. +/// +/// Any field that does not include one of these attributes will be part of the request's JSON +/// body. +/// +/// ## Response +/// +/// Like the request block, the response block consists of normal struct field definitions. +/// Doc comments and attributes are allowed as normal. +/// There is also a special attribute available to control how the struct is created from a +/// `http::Request`: +/// +/// * `#[ruma_api(header = HEADER_NAME)]`: Fields with this attribute will be treated as HTTP +/// headers on the response. +/// The value must implement `AsRef`. +/// Generally this is a `String`. +/// The attribute value shown above as `HEADER_NAME` must be a header name constant from +/// `http::header`, e.g. `CONTENT_TYPE`. +/// +/// Any field that does not include the above attribute will be expected in the response's JSON +/// body. +/// +/// ## Newtype bodies +/// +/// Both the request and response block also support "newtype bodies" by using the +/// `#[ruma_api(body)]` attribute on a field. If present on a field, the entire request or response +/// body will be treated as the value of the field. This allows you to treat the entire request or +/// response body as a specific type, rather than a JSON object with named fields. Only one field in +/// each struct can be marked with this attribute. It is an error to have a newtype body field and +/// normal body fields within the same struct. +/// +/// There is another kind of newtype body that is enabled with `#[ruma_api(raw_body)]`. It is used +/// for endpoints in which the request or response body can be arbitrary bytes instead of a JSON +/// objects. A field with `#[ruma_api(raw_body)]` needs to have the type `Vec`. +/// +/// # Examples +/// +/// ``` +/// pub mod some_endpoint { +/// use ruma_api_macros::ruma_api; +/// +/// ruma_api! { +/// metadata { +/// description: "Does something.", +/// method: POST, +/// name: "some_endpoint", +/// path: "/_matrix/some/endpoint/:baz", +/// rate_limited: false, +/// requires_authentication: false, +/// } +/// +/// request { +/// pub foo: String, +/// +/// #[ruma_api(header = CONTENT_TYPE)] +/// pub content_type: String, +/// +/// #[ruma_api(query)] +/// pub bar: String, +/// +/// #[ruma_api(path)] +/// pub baz: String, +/// } +/// +/// response { +/// #[ruma_api(header = CONTENT_TYPE)] +/// pub content_type: String, +/// +/// pub value: String, +/// } +/// } +/// } +/// +/// pub mod newtype_body_endpoint { +/// use ruma_api_macros::ruma_api; +/// use serde::{Deserialize, Serialize}; +/// +/// #[derive(Clone, Debug, Deserialize, Serialize)] +/// pub struct MyCustomType { +/// pub foo: String, +/// } +/// +/// ruma_api! { +/// metadata { +/// description: "Does something.", +/// method: PUT, +/// name: "newtype_body_endpoint", +/// path: "/_matrix/some/newtype/body/endpoint", +/// rate_limited: false, +/// requires_authentication: false, +/// } +/// +/// request { +/// #[ruma_api(raw_body)] +/// pub file: Vec, +/// } +/// +/// response { +/// #[ruma_api(body)] +/// pub my_custom_type: MyCustomType, +/// } +/// } +/// } +/// ``` +pub use ruma_api_macros::ruma_api; + +pub mod error; +/// This module is used to support the generated code from ruma-api-macros. +/// It is not considered part of ruma-api's public API. +#[doc(hidden)] +pub mod exports { + pub use http; + pub use percent_encoding; + pub use ruma_serde; + pub use serde; + pub use serde_json; +} + +use error::{FromHttpRequestError, FromHttpResponseError, IntoHttpError}; + +/// Gives users the ability to define their own serializable/deserializable errors. +pub trait EndpointError: Sized { + /// Tries to construct `Self` from an `http::Response`. + /// + /// This will always return `Err` variant when no `error` field is defined in + /// the `ruma_api` macro. + fn try_from_response( + response: http::Response>, + ) -> Result; +} + +/// A Matrix API endpoint. +/// +/// The type implementing this trait contains any data needed to make a request to the endpoint. +pub trait Endpoint: + TryInto>, Error = IntoHttpError> + + TryFrom>, Error = FromHttpRequestError> +{ + /// Data returned in a successful response from the endpoint. + type Response: TryInto>, Error = IntoHttpError> + + TryFrom>, Error = FromHttpResponseError>; + + /// Error type returned when response from endpoint fails. + type ResponseError: EndpointError; + + /// Metadata about the endpoint. + const METADATA: Metadata; +} + +/// A Matrix API endpoint that doesn't require authentication. +/// +/// This marker trait is to indicate that a type implementing `Endpoint` doesn't require any authentication. +pub trait NonAuthEndpoint: Endpoint {} + +/// Metadata about an API endpoint. +#[derive(Clone, Debug)] +pub struct Metadata { + /// A human-readable description of the endpoint. + pub description: &'static str, + + /// The HTTP method used by this endpoint. + pub method: Method, + + /// A unique identifier for this endpoint. + pub name: &'static str, + + /// The path of this endpoint's URL, with variable names where path parameters should be filled + /// in during a request. + pub path: &'static str, + + /// Whether or not this endpoint is rate limited by the server. + pub rate_limited: bool, + + /// Whether or not the server requires an authenticated user for this endpoint. + pub requires_authentication: bool, +} + +#[cfg(test)] +mod tests { + /// PUT /_matrix/client/r0/directory/room/:room_alias + pub mod create { + use std::{convert::TryFrom, ops::Deref}; + + use http::{header::CONTENT_TYPE, method::Method}; + use ruma_identifiers::{RoomAliasId, RoomId}; + use serde::{Deserialize, Serialize}; + + use crate::{ + error::{ + FromHttpRequestError, FromHttpResponseError, IntoHttpError, + RequestDeserializationError, ServerError, Void, + }, + Endpoint, Metadata, + }; + + /// A request to create a new room alias. + #[derive(Debug)] + pub struct Request { + pub room_id: RoomId, // body + pub room_alias: RoomAliasId, // path + } + + impl Endpoint for Request { + type Response = Response; + type ResponseError = Void; + + const METADATA: Metadata = Metadata { + description: "Add an alias to a room.", + method: Method::PUT, + name: "create_alias", + path: "/_matrix/client/r0/directory/room/:room_alias", + rate_limited: false, + requires_authentication: true, + }; + } + + impl TryFrom for http::Request> { + type Error = IntoHttpError; + + fn try_from(request: Request) -> Result>, Self::Error> { + let metadata = Request::METADATA; + + let path = metadata + .path + .to_string() + .replace(":room_alias", &request.room_alias.to_string()); + + let request_body = RequestBody { room_id: request.room_id }; + + let http_request = http::Request::builder() + .method(metadata.method) + .uri(path) + .body(serde_json::to_vec(&request_body)?) + // this cannot fail because we don't give user-supplied data to any of the + // builder methods + .unwrap(); + + Ok(http_request) + } + } + + impl TryFrom>> for Request { + type Error = FromHttpRequestError; + + fn try_from(request: http::Request>) -> Result { + let request_body: RequestBody = + match serde_json::from_slice(request.body().as_slice()) { + Ok(body) => body, + Err(err) => { + return Err(RequestDeserializationError::new(err, request).into()); + } + }; + let path_segments: Vec<&str> = request.uri().path()[1..].split('/').collect(); + Ok(Request { + room_id: request_body.room_id, + room_alias: { + let segment = path_segments.get(5).unwrap().as_bytes(); + let decoded = match percent_encoding::percent_decode(segment).decode_utf8() + { + Ok(x) => x, + Err(err) => { + return Err(RequestDeserializationError::new(err, request).into()) + } + }; + match serde_json::from_str(decoded.deref()) { + Ok(id) => id, + Err(err) => { + return Err(RequestDeserializationError::new(err, request).into()) + } + } + }, + }) + } + } + + #[derive(Debug, Serialize, Deserialize)] + struct RequestBody { + room_id: RoomId, + } + + /// The response to a request to create a new room alias. + #[derive(Clone, Copy, Debug)] + pub struct Response; + + impl TryFrom>> for Response { + type Error = FromHttpResponseError; + + fn try_from(http_response: http::Response>) -> Result { + if http_response.status().as_u16() < 400 { + Ok(Response) + } else { + Err(FromHttpResponseError::Http(ServerError::Unknown( + crate::error::ResponseDeserializationError::from_response(http_response), + ))) + } + } + } + + impl TryFrom for http::Response> { + type Error = IntoHttpError; + + fn try_from(_: Response) -> Result>, Self::Error> { + let response = http::Response::builder() + .header(CONTENT_TYPE, "application/json") + .body(b"{}".to_vec()) + .unwrap(); + + Ok(response) + } + } + } +} diff --git a/ruma-api/tests/conversions.rs b/ruma-api/tests/conversions.rs new file mode 100644 index 00000000..3aa11675 --- /dev/null +++ b/ruma-api/tests/conversions.rs @@ -0,0 +1,61 @@ +use ruma_api::ruma_api; +use ruma_identifiers::UserId; + +ruma_api! { + metadata { + description: "Does something.", + method: POST, + name: "my_endpoint", + path: "/_matrix/foo/:bar/:baz", + rate_limited: false, + requires_authentication: false, + } + + request { + pub hello: String, + #[ruma_api(header = CONTENT_TYPE)] + pub world: String, + #[ruma_api(query)] + pub q1: String, + #[ruma_api(query)] + pub q2: u32, + #[ruma_api(path)] + pub bar: String, + #[ruma_api(path)] + pub baz: UserId, + } + + response { + pub hello: String, + #[ruma_api(header = CONTENT_TYPE)] + pub world: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub optional_flag: Option, + } +} + +#[test] +fn request_serde() -> Result<(), Box> { + use std::convert::TryFrom; + + let req = Request { + hello: "hi".to_owned(), + world: "test".to_owned(), + q1: "query_param_special_chars %/&@!".to_owned(), + q2: 55, + bar: "barVal".to_owned(), + baz: UserId::try_from("@bazme:ruma.io")?, + }; + + let http_req = http::Request::>::try_from(req.clone())?; + let req2 = Request::try_from(http_req)?; + + assert_eq!(req.hello, req2.hello); + assert_eq!(req.world, req2.world); + assert_eq!(req.q1, req2.q1); + assert_eq!(req.q2, req2.q2); + assert_eq!(req.bar, req2.bar); + assert_eq!(req.baz, req2.baz); + + Ok(()) +} diff --git a/ruma-api/tests/no_fields.rs b/ruma-api/tests/no_fields.rs new file mode 100644 index 00000000..e2508737 --- /dev/null +++ b/ruma-api/tests/no_fields.rs @@ -0,0 +1,33 @@ +use std::convert::TryFrom; + +use ruma_api::ruma_api; + +ruma_api! { + metadata { + description: "Does something.", + method: GET, + name: "no_fields", + path: "/_matrix/my/endpoint", + rate_limited: false, + requires_authentication: false, + } + + request {} + response {} +} + +#[test] +fn empty_request_http_repr() { + let req = Request {}; + let http_req = http::Request::>::try_from(req).unwrap(); + + assert!(http_req.body().is_empty()); +} + +#[test] +fn empty_response_http_repr() { + let res = Response {}; + let http_res = http::Response::>::try_from(res).unwrap(); + + assert_eq!(http_res.body(), b"{}"); +} diff --git a/ruma-api/tests/ruma_api_macros.rs b/ruma-api/tests/ruma_api_macros.rs new file mode 100644 index 00000000..b4bf6434 --- /dev/null +++ b/ruma-api/tests/ruma_api_macros.rs @@ -0,0 +1,135 @@ +pub mod some_endpoint { + use ruma_api::ruma_api; + use ruma_events::{collections::all, tag::TagEvent, EventJson}; + + ruma_api! { + metadata { + description: "Does something.", + method: POST, // An `http::Method` constant. No imports required. + name: "some_endpoint", + path: "/_matrix/some/endpoint/:baz", + rate_limited: false, + requires_authentication: false, + } + + request { + // With no attribute on the field, it will be put into the body of the request. + pub foo: String, + + // This value will be put into the "Content-Type" HTTP header. + #[ruma_api(header = CONTENT_TYPE)] + pub content_type: String, + + // This value will be put into the query string of the request's URL. + #[ruma_api(query)] + pub bar: String, + + // This value will be inserted into the request's URL in place of the + // ":baz" path component. + #[ruma_api(path)] + pub baz: String, + } + + response { + // This value will be extracted from the "Content-Type" HTTP header. + #[ruma_api(header = CONTENT_TYPE)] + pub content_type: String, + + // With no attribute on the field, it will be extracted from the body of the response. + pub value: String, + + // You can use serde attributes on any kind of field + #[serde(skip_serializing_if = "Option::is_none")] + pub optional_flag: Option, + + // Use `EventJson` instead of the actual event to allow additional fields to be sent... + pub event: EventJson, + + // ... and to allow unknown events when the endpoint deals with event collections. + pub list_of_events: Vec>, + } + } +} + +pub mod newtype_body_endpoint { + use ruma_api::ruma_api; + + #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] + pub struct MyCustomType { + pub foo: String, + } + + ruma_api! { + metadata { + description: "Does something.", + method: PUT, + name: "newtype_body_endpoint", + path: "/_matrix/some/newtype/body/endpoint", + rate_limited: false, + requires_authentication: false, + } + + request { + #[ruma_api(body)] + pub list_of_custom_things: Vec, + } + + response { + #[ruma_api(body)] + pub my_custom_thing: MyCustomType, + } + } +} + +pub mod newtype_raw_body_endpoint { + use ruma_api::ruma_api; + + #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] + pub struct MyCustomType { + pub foo: String, + } + + ruma_api! { + metadata { + description: "Does something.", + method: PUT, + name: "newtype_body_endpoint", + path: "/_matrix/some/newtype/body/endpoint", + rate_limited: false, + requires_authentication: false, + } + + request { + #[ruma_api(raw_body)] + pub file: Vec, + } + + response { + #[ruma_api(raw_body)] + pub file: Vec, + } + } +} + +pub mod query_map_endpoint { + use ruma_api::ruma_api; + + ruma_api! { + metadata { + description: "Does something.", + method: GET, + name: "newtype_body_endpoint", + path: "/_matrix/some/query/map/endpoint", + rate_limited: false, + requires_authentication: false, + } + + request { + #[ruma_api(query_map)] + pub fields: Vec<(String, String)>, + } + + response { + } + } +}