Skip to main content

soaprs_http/
api.rs

1//! Validation- and documentation-neutral API contract metadata.
2
3use std::fmt;
4
5use http::StatusCode;
6use soaprs_core::{SoapError, SoapResult};
7
8use crate::ContractId;
9
10/// Validated media type without a dependency on a serializer or schema format.
11#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
12pub struct MediaType(String);
13
14impl MediaType {
15    /// Validates a `type/subtype` media type, optionally with parameters.
16    pub fn new(value: impl Into<String>) -> SoapResult<Self> {
17        let value = value.into();
18        let mut segments = value.split(';');
19        let essence = segments.next().unwrap_or_default().trim();
20        let Some((kind, subtype)) = essence.split_once('/') else {
21            return Err(SoapError::validation(format!(
22                "invalid media type `{value}`"
23            )));
24        };
25        if !valid_http_token(kind)
26            || !valid_http_token(subtype)
27            || value.chars().any(|character| character.is_control())
28            || segments.any(|parameter| !valid_media_parameter(parameter.trim()))
29        {
30            return Err(SoapError::validation(format!(
31                "invalid media type `{value}`"
32            )));
33        }
34        Ok(Self(value))
35    }
36
37    /// Returns the complete media type including any parameters.
38    pub fn as_str(&self) -> &str {
39        &self.0
40    }
41
42    /// Reports case-insensitive equality for contract registration.
43    pub fn equivalent_to(&self, other: &Self) -> bool {
44        self.0.eq_ignore_ascii_case(&other.0)
45    }
46
47    /// Returns `application/json`.
48    pub fn json() -> Self {
49        Self("application/json".to_owned())
50    }
51}
52
53impl fmt::Display for MediaType {
54    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
55        formatter.write_str(&self.0)
56    }
57}
58
59fn valid_http_token(value: &str) -> bool {
60    !value.is_empty()
61        && value.chars().all(|character| {
62            character.is_ascii_alphanumeric()
63                || matches!(
64                    character,
65                    '!' | '#'
66                        | '$'
67                        | '%'
68                        | '&'
69                        | '\''
70                        | '*'
71                        | '+'
72                        | '-'
73                        | '.'
74                        | '^'
75                        | '_'
76                        | '`'
77                        | '|'
78                        | '~'
79                )
80        })
81}
82
83fn valid_media_parameter(parameter: &str) -> bool {
84    let Some((name, value)) = parameter.split_once('=') else {
85        return false;
86    };
87    if !valid_http_token(name.trim()) {
88        return false;
89    }
90    let value = value.trim();
91    valid_http_token(value)
92        || (value.len() >= 2
93            && value.starts_with('"')
94            && value.ends_with('"')
95            && valid_quoted_value(&value[1..value.len() - 1]))
96}
97
98fn valid_quoted_value(value: &str) -> bool {
99    let mut escaped = false;
100    for character in value.chars() {
101        if character.is_control() {
102            return false;
103        }
104        if escaped {
105            escaped = false;
106        } else if character == '\\' {
107            escaped = true;
108        } else if character == '"' {
109            return false;
110        }
111    }
112    !escaped
113}
114
115/// Location from which an HTTP adapter validates or documents request data.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
117pub enum RequestContractLocation {
118    /// Request body after framework extraction.
119    Body,
120    /// Parsed query parameters.
121    Query,
122    /// Parsed route parameters.
123    Path,
124    /// Request headers.
125    Headers,
126}
127
128/// Logical validation/schema contract attached to part of a request.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct RequestContract {
131    /// Contract resolved by a validation or schema adapter.
132    pub id: ContractId,
133    /// Request component covered by the contract.
134    pub location: RequestContractLocation,
135    /// Content type expected for body contracts.
136    pub content_type: Option<MediaType>,
137}
138
139impl RequestContract {
140    /// Creates a request contract reference.
141    pub const fn new(id: ContractId, location: RequestContractLocation) -> Self {
142        Self {
143            id,
144            location,
145            content_type: None,
146        }
147    }
148
149    /// Associates the contract with one body content type.
150    #[must_use]
151    pub fn content_type(mut self, content_type: MediaType) -> Self {
152        self.content_type = Some(content_type);
153        self
154    }
155}
156
157/// Logical response schema contract attached to a status code.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct ResponseContract {
160    /// HTTP response status represented by the contract.
161    pub status: StatusCode,
162    /// Contract resolved by a schema adapter.
163    pub id: ContractId,
164    /// Serialized response content type.
165    pub content_type: MediaType,
166}
167
168impl ResponseContract {
169    /// Creates a JSON response contract reference.
170    pub fn json(status: StatusCode, id: ContractId) -> Self {
171        Self {
172            status,
173            id,
174            content_type: MediaType::json(),
175        }
176    }
177}
178
179/// Request and response contracts attached to one endpoint.
180#[derive(Debug, Clone, Default, PartialEq, Eq)]
181pub struct EndpointContracts {
182    requests: Vec<RequestContract>,
183    responses: Vec<ResponseContract>,
184}
185
186impl EndpointContracts {
187    /// Adds or replaces the contract for one request location.
188    pub fn add_request(&mut self, contract: RequestContract) {
189        if let Some(existing) = self
190            .requests
191            .iter_mut()
192            .find(|item| same_request_slot(item, &contract))
193        {
194            *existing = contract;
195        } else {
196            self.requests.push(contract);
197        }
198    }
199
200    /// Adds or replaces the response contract for one status code.
201    pub fn add_response(&mut self, contract: ResponseContract) {
202        if let Some(existing) = self.responses.iter_mut().find(|item| {
203            item.status == contract.status
204                && item.content_type.equivalent_to(&contract.content_type)
205        }) {
206            *existing = contract;
207        } else {
208            self.responses.push(contract);
209        }
210    }
211
212    /// Returns request contract references in registration order.
213    pub fn requests(&self) -> &[RequestContract] {
214        &self.requests
215    }
216
217    /// Returns response contract references in registration order.
218    pub fn responses(&self) -> &[ResponseContract] {
219        &self.responses
220    }
221}
222
223fn same_request_slot(left: &RequestContract, right: &RequestContract) -> bool {
224    left.location == right.location
225        && (left.location != RequestContractLocation::Body
226            || match (&left.content_type, &right.content_type) {
227                (Some(left), Some(right)) => left.equivalent_to(right),
228                (None, None) => true,
229                _ => false,
230            })
231}
232
233/// Human-facing operation documentation independent from OpenAPI structures.
234#[derive(Debug, Clone, Default, PartialEq, Eq)]
235pub struct OperationDocumentation {
236    /// Short operation summary.
237    pub summary: Option<String>,
238    /// Longer operation description.
239    pub description: Option<String>,
240    /// Whether clients should stop adopting this operation.
241    pub deprecated: bool,
242}
243
244impl OperationDocumentation {
245    /// Sets a non-empty operation summary.
246    pub fn summary(mut self, summary: impl Into<String>) -> SoapResult<Self> {
247        self.summary = Some(non_empty("operation summary", summary.into())?);
248        Ok(self)
249    }
250
251    /// Sets a non-empty operation description.
252    pub fn description(mut self, description: impl Into<String>) -> SoapResult<Self> {
253        self.description = Some(non_empty("operation description", description.into())?);
254        Ok(self)
255    }
256
257    /// Marks the operation as deprecated.
258    #[must_use]
259    pub const fn deprecated(mut self) -> Self {
260        self.deprecated = true;
261        self
262    }
263
264    /// Validates documentation after direct public-field mutation.
265    pub fn validate(&self) -> SoapResult<()> {
266        if self
267            .summary
268            .as_ref()
269            .is_some_and(|value| value.trim().is_empty())
270        {
271            return Err(SoapError::validation("operation summary cannot be empty"));
272        }
273        if self
274            .description
275            .as_ref()
276            .is_some_and(|value| value.trim().is_empty())
277        {
278            return Err(SoapError::validation(
279                "operation description cannot be empty",
280            ));
281        }
282        Ok(())
283    }
284}
285
286fn non_empty(kind: &str, value: String) -> SoapResult<String> {
287    if value.trim().is_empty() {
288        Err(SoapError::validation(format!("{kind} cannot be empty")))
289    } else {
290        Ok(value)
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use http::StatusCode;
297
298    use super::{
299        EndpointContracts, MediaType, RequestContract, RequestContractLocation, ResponseContract,
300    };
301    use crate::ContractId;
302
303    #[test]
304    fn contracts_replace_one_logical_content_slot_and_preserve_other_formats() {
305        let mut contracts = EndpointContracts::default();
306        let Some(first) = ContractId::new("users.request.v1").ok() else {
307            panic!("valid contract id");
308        };
309        let Some(second) = ContractId::new("users.request.v2").ok() else {
310            panic!("valid contract id");
311        };
312        contracts.add_request(
313            RequestContract::new(first, RequestContractLocation::Body)
314                .content_type(MediaType::json()),
315        );
316        contracts.add_request(
317            RequestContract::new(second.clone(), RequestContractLocation::Body)
318                .content_type(MediaType::json()),
319        );
320        contracts.add_response(ResponseContract::json(StatusCode::OK, second));
321
322        let Some(protobuf) = MediaType::new("application/protobuf").ok() else {
323            panic!("valid media type");
324        };
325        let Some(protobuf_id) = ContractId::new("users.response.protobuf").ok() else {
326            panic!("valid contract id");
327        };
328        contracts.add_response(ResponseContract {
329            status: StatusCode::OK,
330            id: protobuf_id,
331            content_type: protobuf,
332        });
333
334        assert_eq!(contracts.requests().len(), 1);
335        assert_eq!(contracts.responses().len(), 2);
336        assert_eq!(contracts.requests()[0].id.as_str(), "users.request.v2");
337    }
338
339    #[test]
340    fn media_types_require_a_valid_type_and_subtype() {
341        assert!(MediaType::new("application/problem+json").is_ok());
342        assert!(MediaType::new("application/json; charset=utf-8").is_ok());
343        assert!(MediaType::new("application/json; profile=\"public api\"").is_ok());
344        assert!(MediaType::new("json").is_err());
345        assert!(MediaType::new("application/white space").is_err());
346        assert!(MediaType::new("application/json; charset").is_err());
347    }
348}