1use std::fmt;
4
5use http::StatusCode;
6use soaprs_core::{SoapError, SoapResult};
7
8use crate::ContractId;
9
10#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
12pub struct MediaType(String);
13
14impl MediaType {
15 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 pub fn as_str(&self) -> &str {
39 &self.0
40 }
41
42 pub fn equivalent_to(&self, other: &Self) -> bool {
44 self.0.eq_ignore_ascii_case(&other.0)
45 }
46
47 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
117pub enum RequestContractLocation {
118 Body,
120 Query,
122 Path,
124 Headers,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct RequestContract {
131 pub id: ContractId,
133 pub location: RequestContractLocation,
135 pub content_type: Option<MediaType>,
137}
138
139impl RequestContract {
140 pub const fn new(id: ContractId, location: RequestContractLocation) -> Self {
142 Self {
143 id,
144 location,
145 content_type: None,
146 }
147 }
148
149 #[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#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct ResponseContract {
160 pub status: StatusCode,
162 pub id: ContractId,
164 pub content_type: MediaType,
166}
167
168impl ResponseContract {
169 pub fn json(status: StatusCode, id: ContractId) -> Self {
171 Self {
172 status,
173 id,
174 content_type: MediaType::json(),
175 }
176 }
177}
178
179#[derive(Debug, Clone, Default, PartialEq, Eq)]
181pub struct EndpointContracts {
182 requests: Vec<RequestContract>,
183 responses: Vec<ResponseContract>,
184}
185
186impl EndpointContracts {
187 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 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 pub fn requests(&self) -> &[RequestContract] {
214 &self.requests
215 }
216
217 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
235pub struct OperationDocumentation {
236 pub summary: Option<String>,
238 pub description: Option<String>,
240 pub deprecated: bool,
242}
243
244impl OperationDocumentation {
245 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 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 #[must_use]
259 pub const fn deprecated(mut self) -> Self {
260 self.deprecated = true;
261 self
262 }
263
264 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}