api: Disallow #[serde(flatten)] for single-body-fields of requests and responses

`#[ruma_api(body)]` must be used instead.
This commit is contained in:
Kévin Commaille 2024-12-10 16:09:53 +01:00 committed by strawberry
parent 3ca8adaadf
commit f05d0e03a1
10 changed files with 167 additions and 9 deletions

View File

@ -37,7 +37,7 @@ pub mod v3 {
pub user_id: OwnedUserId,
/// Whether the user is typing within a length of time or not.
#[serde(flatten)]
#[ruma_api(body)]
pub state: Typing,
}

View File

@ -1,5 +1,10 @@
# [unreleased]
Breaking changes:
- `#[serde(flatten)]` on the only body field of a `#[request]` or `#[response]`
struct is disallowed. `#[ruma_api(body)]` must be used instead.
Improvements:
- The `ruma_identifiers_storage` compile-time `cfg` setting can also be

View File

@ -7,4 +7,6 @@ fn ui() {
t.pass("tests/it/api/ui/response-only.rs");
t.compile_fail("tests/it/api/ui/deprecated-without-added.rs");
t.compile_fail("tests/it/api/ui/removed-without-deprecated.rs");
t.compile_fail("tests/it/api/ui/serde-flatten-request-body.rs");
t.compile_fail("tests/it/api/ui/serde-flatten-response-body.rs");
}

View File

@ -0,0 +1,30 @@
use ruma_common::{
api::{request, response, Metadata},
metadata,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomRequestBody {
pub bar: String,
}
const METADATA: Metadata = metadata! {
method: POST, // An `http::Method` constant. No imports required.
rate_limited: false,
authentication: None,
history: {
unstable => "/_matrix/some/endpoint",
}
};
#[request]
pub struct Request {
#[serde(flatten)]
pub foo: CustomRequestBody,
}
#[response]
pub struct Response;
fn main() {}

View File

@ -0,0 +1,6 @@
error: Use `#[ruma_api(body)]` to represent the JSON body as a single field
--> tests/it/api/ui/serde-flatten-request-body.rs:23:5
|
23 | / #[serde(flatten)]
24 | | pub foo: CustomRequestBody,
| |______________________________^

View File

@ -0,0 +1,30 @@
use ruma_common::{
api::{request, response, Metadata},
metadata,
};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CustomResponseBody {
pub bar: String,
}
const METADATA: Metadata = metadata! {
method: GET, // An `http::Method` constant. No imports required.
rate_limited: false,
authentication: None,
history: {
unstable => "/_matrix/some/endpoint",
}
};
#[request]
pub struct Request;
#[response]
pub struct Response {
#[serde(flatten)]
pub foo: CustomResponseBody,
}
fn main() {}

View File

@ -0,0 +1,33 @@
error: Use `#[ruma_api(body)]` to represent the JSON body as a single field
--> tests/it/api/ui/serde-flatten-response-body.rs:26:5
|
26 | / #[serde(flatten)]
27 | | pub foo: CustomResponseBody,
| |_______________________________^
error[E0277]: the trait bound `Response: IncomingResponse` is not satisfied
--> tests/it/api/ui/serde-flatten-response-body.rs:21:1
|
21 | #[request]
| ^^^^^^^^^^ the trait `IncomingResponse` is not implemented for `Response`
|
note: required by a bound in `ruma_common::api::OutgoingRequest::IncomingResponse`
--> src/api.rs
|
| type IncomingResponse: IncomingResponse<EndpointError = Self::EndpointError>;
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `OutgoingRequest::IncomingResponse`
= note: this error originates in the derive macro `::ruma_common::exports::ruma_macros::Request` (in Nightly builds, run with -Z macro-backtrace for more info)
error[E0277]: the trait bound `Response: OutgoingResponse` is not satisfied
--> tests/it/api/ui/serde-flatten-response-body.rs:21:1
|
21 | #[request]
| ^^^^^^^^^^ the trait `OutgoingResponse` is not implemented for `Response`
|
= help: the trait `OutgoingResponse` is implemented for `MatrixError`
note: required by a bound in `ruma_common::api::IncomingRequest::OutgoingResponse`
--> src/api.rs
|
| type OutgoingResponse: OutgoingResponse;
| ^^^^^^^^^^^^^^^^ required by this bound in `IncomingRequest::OutgoingResponse`
= note: this error originates in the derive macro `::ruma_common::exports::ruma_macros::Request` (in Nightly builds, run with -Z macro-backtrace for more info)

View File

@ -11,7 +11,7 @@ use super::{
attribute::{DeriveRequestMeta, RequestMeta},
ensure_feature_presence,
};
use crate::util::{import_ruma_common, PrivateField};
use crate::util::{field_has_serde_flatten_attribute, import_ruma_common, PrivateField};
mod incoming;
mod outgoing;
@ -251,9 +251,10 @@ impl Request {
}
};
let has_body_fields = self.fields.iter().any(|f| matches!(&f.kind, RequestFieldKind::Body));
let has_query_fields =
self.fields.iter().any(|f| matches!(&f.kind, RequestFieldKind::Query));
let mut body_fields =
self.fields.iter().filter(|f| matches!(f.kind, RequestFieldKind::Body));
let first_body_field = body_fields.next();
let has_body_fields = first_body_field.is_some();
if has_newtype_body_field && has_body_fields {
return Err(syn::Error::new_spanned(
@ -262,6 +263,18 @@ impl Request {
));
}
if let Some(first_body_field) = first_body_field {
let is_single_body_field = body_fields.next().is_none();
if is_single_body_field && field_has_serde_flatten_attribute(&first_body_field.inner) {
return Err(syn::Error::new_spanned(
first_body_field,
"Use `#[ruma_api(body)]` to represent the JSON body as a single field",
));
}
}
let has_query_fields = self.has_query_fields();
if has_query_all_field && has_query_fields {
return Err(syn::Error::new_spanned(
&self.ident,

View File

@ -14,7 +14,7 @@ use super::{
attribute::{DeriveResponseMeta, ResponseMeta},
ensure_feature_presence,
};
use crate::util::{import_ruma_common, PrivateField};
use crate::util::{field_has_serde_flatten_attribute, import_ruma_common, PrivateField};
mod incoming;
mod outgoing;
@ -213,8 +213,11 @@ impl Response {
}
};
let has_body_fields =
self.fields.iter().any(|f| matches!(&f.kind, ResponseFieldKind::Body));
let mut body_fields =
self.fields.iter().filter(|f| matches!(f.kind, ResponseFieldKind::Body));
let first_body_field = body_fields.next();
let has_body_fields = first_body_field.is_some();
if has_newtype_body_field && has_body_fields {
return Err(syn::Error::new_spanned(
&self.ident,
@ -222,6 +225,17 @@ impl Response {
));
}
if let Some(first_body_field) = first_body_field {
let is_single_body_field = body_fields.next().is_none();
if is_single_body_field && field_has_serde_flatten_attribute(&first_body_field.inner) {
return Err(syn::Error::new_spanned(
first_body_field,
"Use `#[ruma_api(body)]` to represent the JSON body as a single field",
));
}
}
Ok(())
}
}

View File

@ -1,7 +1,7 @@
use proc_macro2::TokenStream;
use proc_macro_crate::{crate_name, FoundCrate};
use quote::{format_ident, quote, ToTokens};
use syn::{Field, Ident, LitStr};
use syn::{Attribute, Field, Ident, LitStr};
pub(crate) fn import_ruma_common() -> TokenStream {
if let Ok(FoundCrate::Name(name)) = crate_name("ruma-common") {
@ -202,3 +202,28 @@ pub fn cfg_expand_struct(item: &mut syn::ItemStruct) {
fields.named.push(field);
}
}
/// Whether the given field has a `#[serde(flatten)]` attribute.
pub fn field_has_serde_flatten_attribute(field: &Field) -> bool {
field.attrs.iter().any(is_serde_flatten_attribute)
}
/// Whether the given attribute is a `#[serde(flatten)]` attribute.
fn is_serde_flatten_attribute(attr: &Attribute) -> bool {
if !attr.path().is_ident("serde") {
return false;
}
let mut contains_flatten = false;
let _ = attr.parse_nested_meta(|meta| {
if meta.path.is_ident("flatten") {
contains_flatten = true;
// Return an error to stop the parsing early.
return Err(meta.error("found"));
}
Ok(())
});
contains_flatten
}