Skip to main content

ferriskey_sdk/
encoding.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
4use serde_json::Value;
5
6use crate::{
7    client::OperationInput,
8    error::SdkError,
9    generated::{
10        GeneratedOperationDescriptor, GeneratedParameterDescriptor, GeneratedResponseDescriptor,
11        ParameterLocation,
12    },
13    transport::{SdkRequest, SdkResponse},
14};
15
16/// Decoded response payload returned by the generic SDK pipeline.
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct DecodedResponse {
19    /// Response headers preserved as UTF-8 strings when possible.
20    pub headers: BTreeMap<String, String>,
21    /// Parsed JSON body when the documented response content type is JSON.
22    pub json_body: Option<Value>,
23    /// Raw response body bytes.
24    pub raw_body: Vec<u8>,
25    /// Matched response schema name when documented.
26    pub schema_name: Option<&'static str>,
27    /// HTTP status code returned by the server.
28    pub status: u16,
29}
30
31impl DecodedResponse {
32    /// Access the decoded JSON body when present.
33    #[must_use]
34    pub const fn json_body(&self) -> Option<&Value> {
35        self.json_body.as_ref()
36    }
37}
38
39pub(crate) fn encode_request(
40    descriptor: &'static GeneratedOperationDescriptor,
41    input: OperationInput,
42) -> Result<SdkRequest, SdkError> {
43    let mut headers = input.headers;
44    let body = encode_body(descriptor, input.body, &mut headers)?;
45    let path = encode_path(descriptor, &input.path_params)?;
46    let path = encode_query(descriptor, &path, &input.query_params)?;
47    validate_required_headers(descriptor, &headers)?;
48
49    Ok(SdkRequest {
50        body,
51        headers,
52        method: descriptor.method.to_string(),
53        path,
54        requires_auth: descriptor.requires_auth,
55    })
56}
57
58pub(crate) fn decode_response(
59    descriptor: &'static GeneratedOperationDescriptor,
60    response: SdkResponse,
61) -> Result<DecodedResponse, SdkError> {
62    let matched_response = match_response(descriptor, response.status)?;
63    let json_body = decode_json_body(matched_response, &response.body)?;
64    let decoded = DecodedResponse {
65        headers: response.headers,
66        json_body: json_body.clone(),
67        raw_body: response.body,
68        schema_name: matched_response.schema_name,
69        status: matched_response.status,
70    };
71
72    if matched_response.is_error {
73        return Err(SdkError::ApiResponse {
74            body: json_body,
75            operation_id: descriptor.operation_id.to_string(),
76            schema_name: matched_response.schema_name,
77            status: matched_response.status,
78        });
79    }
80
81    Ok(decoded)
82}
83
84fn encode_body(
85    descriptor: &'static GeneratedOperationDescriptor,
86    body: Option<Vec<u8>>,
87    headers: &mut BTreeMap<String, String>,
88) -> Result<Option<Vec<u8>>, SdkError> {
89    let Some(request_body) = descriptor.request_body else {
90        return Ok(None);
91    };
92
93    if body.is_none() && request_body.required && !request_body.nullable {
94        return Err(SdkError::MissingRequestBody {
95            operation_id: descriptor.operation_id.to_string(),
96        });
97    }
98
99    if body.is_some() &&
100        let Some(content_type) = request_body.content_type
101    {
102        headers.entry("content-type".to_string()).or_insert_with(|| content_type.to_string());
103    }
104
105    Ok(body)
106}
107
108fn encode_path(
109    descriptor: &'static GeneratedOperationDescriptor,
110    path_params: &BTreeMap<String, String>,
111) -> Result<String, SdkError> {
112    let mut encoded_path = descriptor.path.to_string();
113
114    for parameter in descriptor.parameters.iter().filter(is_path_parameter) {
115        let value = path_params
116            .get(parameter.name)
117            .ok_or_else(|| missing_parameter(descriptor, parameter))?;
118        let placeholder = format!("{{{}}}", parameter.name);
119        encoded_path = encoded_path.replace(&placeholder, &encode_component(value));
120    }
121
122    if encoded_path.contains('{') || encoded_path.contains('}') {
123        return Err(SdkError::InvalidPathTemplate {
124            operation_id: descriptor.operation_id.to_string(),
125            path_template: descriptor.path.to_string(),
126        });
127    }
128
129    Ok(encoded_path)
130}
131
132fn encode_query(
133    descriptor: &'static GeneratedOperationDescriptor,
134    path: &str,
135    query_params: &BTreeMap<String, Vec<String>>,
136) -> Result<String, SdkError> {
137    let known_query_names = descriptor
138        .parameters
139        .iter()
140        .filter(is_query_parameter)
141        .map(|parameter| parameter.name)
142        .collect::<BTreeSet<_>>();
143    let mut encoded_pairs = Vec::new();
144
145    for parameter in descriptor.parameters.iter().filter(is_query_parameter) {
146        if parameter.required && !query_params.contains_key(parameter.name) {
147            return Err(missing_parameter(descriptor, parameter));
148        }
149
150        if let Some(values) = query_params.get(parameter.name) {
151            encoded_pairs.extend(values.iter().map(|value| {
152                format!("{}={}", encode_component(parameter.name), encode_component(value))
153            }));
154        }
155    }
156
157    for (name, values) in query_params {
158        if known_query_names.contains(name.as_str()) {
159            continue;
160        }
161
162        encoded_pairs.extend(
163            values
164                .iter()
165                .map(|value| format!("{}={}", encode_component(name), encode_component(value))),
166        );
167    }
168
169    if encoded_pairs.is_empty() {
170        return Ok(path.to_string());
171    }
172
173    Ok(format!("{path}?{}", encoded_pairs.join("&")))
174}
175
176fn validate_required_headers(
177    descriptor: &'static GeneratedOperationDescriptor,
178    headers: &BTreeMap<String, String>,
179) -> Result<(), SdkError> {
180    for parameter in descriptor.parameters.iter().filter(is_header_parameter) {
181        if parameter.required && !headers.contains_key(parameter.name) {
182            return Err(missing_parameter(descriptor, parameter));
183        }
184    }
185
186    Ok(())
187}
188
189fn match_response(
190    descriptor: &'static GeneratedOperationDescriptor,
191    status: u16,
192) -> Result<&'static GeneratedResponseDescriptor, SdkError> {
193    descriptor.responses.iter().find(|response| response.status == status).ok_or_else(|| {
194        SdkError::UnexpectedStatus { actual: status, expected: descriptor.primary_success_status }
195    })
196}
197
198fn decode_json_body(
199    response: &'static GeneratedResponseDescriptor,
200    body: &[u8],
201) -> Result<Option<Value>, SdkError> {
202    if body.is_empty() {
203        return Ok(None);
204    }
205
206    let expects_json = response.content_type.map_or_else(
207        || response.schema_name.is_some(),
208        |content_type| content_type == "application/json" || content_type.ends_with("+json"),
209    );
210
211    if !expects_json {
212        return Ok(None);
213    }
214
215    serde_json::from_slice(body).map(Some).map_err(SdkError::Decode)
216}
217
218fn missing_parameter(
219    descriptor: &'static GeneratedOperationDescriptor,
220    parameter: &'static GeneratedParameterDescriptor,
221) -> SdkError {
222    SdkError::MissingParameter {
223        location: match parameter.location {
224            ParameterLocation::Header => "header",
225            ParameterLocation::Path => "path",
226            ParameterLocation::Query => "query",
227        },
228        name: parameter.name.to_string(),
229        operation_id: descriptor.operation_id.to_string(),
230    }
231}
232
233fn is_header_parameter(parameter: &&GeneratedParameterDescriptor) -> bool {
234    parameter.location == ParameterLocation::Header
235}
236
237fn is_path_parameter(parameter: &&GeneratedParameterDescriptor) -> bool {
238    parameter.location == ParameterLocation::Path
239}
240
241fn is_query_parameter(parameter: &&GeneratedParameterDescriptor) -> bool {
242    parameter.location == ParameterLocation::Query
243}
244
245fn encode_component(value: &str) -> String {
246    utf8_percent_encode(value, NON_ALPHANUMERIC).to_string()
247}