Skip to main content

onshape_client_core/
request.rs

1//! HTTP request types for the Onshape API.
2//!
3//! These types represent API requests as pure data — no I/O is performed here.
4//! The I/O layer (`onshape-client-io`) interprets these to make actual HTTP calls.
5
6use std::str::FromStr;
7
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12// ============================================================================
13// HTTP Method
14// ============================================================================
15
16/// HTTP method for an API request.
17///
18/// JSON serialization/deserialization is **uppercase-only** (via `serde(rename_all = "UPPERCASE")`),
19/// matching the HTTP convention for method tokens in structured payloads.
20/// [`FromStr`] is **case-insensitive** (e.g. `"get"`, `"Get"`, `"GET"` all parse successfully),
21/// to accommodate sources like `OpenAPI` spec keys that use lowercase by convention.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
23#[serde(rename_all = "UPPERCASE")]
24pub enum HttpMethod {
25    Get,
26    Post,
27    Put,
28    Delete,
29    Patch,
30}
31
32/// Error returned when parsing an unrecognized HTTP method string.
33#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
34#[error("unknown HTTP method: {0}")]
35pub struct UnknownHttpMethod(pub String);
36
37impl FromStr for HttpMethod {
38    type Err = UnknownHttpMethod;
39
40    fn from_str(s: &str) -> Result<Self, Self::Err> {
41        match s.to_lowercase().as_str() {
42            "get" => Ok(Self::Get),
43            "post" => Ok(Self::Post),
44            "put" => Ok(Self::Put),
45            "delete" => Ok(Self::Delete),
46            "patch" => Ok(Self::Patch),
47            _ => Err(UnknownHttpMethod(s.to_string())),
48        }
49    }
50}
51
52impl std::fmt::Display for HttpMethod {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        match self {
55            Self::Get => write!(f, "GET"),
56            Self::Post => write!(f, "POST"),
57            Self::Put => write!(f, "PUT"),
58            Self::Delete => write!(f, "DELETE"),
59            Self::Patch => write!(f, "PATCH"),
60        }
61    }
62}
63
64// ============================================================================
65// API Request
66// ============================================================================
67
68/// An HTTP request to the Onshape API, produced as an effect by `build_request`.
69///
70/// This is a pure data structure describing what HTTP call to make.
71/// It does not include the base URL or authentication — those are added
72/// by the I/O layer when executing the request.
73#[derive(Clone, Debug, Serialize, Deserialize)]
74pub struct ApiRequest {
75    /// HTTP method.
76    pub method: HttpMethod,
77    /// Fully resolved URL path (path params substituted), e.g. `/documents/abc123`.
78    pub path: String,
79    /// Query parameters.
80    pub query_params: Vec<(String, String)>,
81    /// Request body, if any.
82    pub body: Option<RequestBody>,
83    /// Content type for the request body.
84    pub content_type: Option<String>,
85}
86
87// ============================================================================
88// Request Body
89// ============================================================================
90
91/// The body of an API request.
92///
93/// Different content types require different body representations. The I/O
94/// layer uses this to decide how to serialize and send the body.
95#[derive(Clone, Debug, Serialize, Deserialize)]
96pub enum RequestBody {
97    /// A JSON body — serialized via `serde_json`.
98    Json(Value),
99    /// A multipart form body — text fields plus binary file parts.
100    /// The I/O layer builds a `multipart/form-data` request from this.
101    Multipart(MultipartBody),
102}
103
104/// A multipart form body with text and binary parts.
105#[derive(Clone, Debug, Serialize, Deserialize)]
106pub struct MultipartBody {
107    /// Text form fields: `(field_name, value)`.
108    pub text_fields: Vec<(String, String)>,
109    /// Binary form fields (e.g., file uploads).
110    pub binary_fields: Vec<BinaryField>,
111}
112
113/// A single binary field in a multipart form.
114#[derive(Clone, Debug, Serialize, Deserialize)]
115pub struct BinaryField {
116    /// The form field name (must match the schema property name).
117    pub field_name: String,
118    /// The raw binary content.
119    pub data: Vec<u8>,
120    /// Optional MIME type for this part (e.g., `application/octet-stream`).
121    pub content_type: Option<String>,
122}
123
124impl RequestBody {
125    /// Convenience: extract the inner [`Value`] if this is a `Json` variant.
126    ///
127    /// Returns `None` for non-JSON variants.
128    #[must_use]
129    pub const fn as_json(&self) -> Option<&Value> {
130        match self {
131            Self::Json(v) => Some(v),
132            Self::Multipart(_) => None,
133        }
134    }
135}
136
137// ============================================================================
138// API Response
139// ============================================================================
140
141/// A raw HTTP response from the Onshape API.
142///
143/// This is the minimal data the I/O layer returns after executing an [`ApiRequest`].
144/// Higher layers interpret the status code and body as needed.
145#[derive(Clone, Debug)]
146pub struct ApiResponse {
147    /// HTTP status code.
148    pub status: u16,
149    /// Response body as a string.
150    pub body: String,
151}
152
153// ============================================================================
154// Tests
155// ============================================================================
156
157#[cfg(test)]
158#[allow(clippy::expect_used)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn http_method_from_str_lowercase() {
164        assert_eq!(HttpMethod::from_str("get"), Ok(HttpMethod::Get));
165        assert_eq!(HttpMethod::from_str("post"), Ok(HttpMethod::Post));
166        assert_eq!(HttpMethod::from_str("put"), Ok(HttpMethod::Put));
167        assert_eq!(HttpMethod::from_str("delete"), Ok(HttpMethod::Delete));
168        assert_eq!(HttpMethod::from_str("patch"), Ok(HttpMethod::Patch));
169    }
170
171    #[test]
172    fn http_method_from_str_uppercase() {
173        assert_eq!(HttpMethod::from_str("GET"), Ok(HttpMethod::Get));
174        assert_eq!(HttpMethod::from_str("POST"), Ok(HttpMethod::Post));
175    }
176
177    #[test]
178    fn http_method_from_str_mixed_case() {
179        assert_eq!(HttpMethod::from_str("Get"), Ok(HttpMethod::Get));
180        assert_eq!(HttpMethod::from_str("PoSt"), Ok(HttpMethod::Post));
181    }
182
183    #[test]
184    fn http_method_from_str_unknown() {
185        assert!(HttpMethod::from_str("TRACE").is_err());
186        assert!(HttpMethod::from_str("").is_err());
187    }
188
189    #[test]
190    fn http_method_display() {
191        assert_eq!(HttpMethod::Get.to_string(), "GET");
192        assert_eq!(HttpMethod::Post.to_string(), "POST");
193        assert_eq!(HttpMethod::Put.to_string(), "PUT");
194        assert_eq!(HttpMethod::Delete.to_string(), "DELETE");
195        assert_eq!(HttpMethod::Patch.to_string(), "PATCH");
196    }
197
198    #[test]
199    fn http_method_serializes_uppercase() {
200        let json = serde_json::to_string(&HttpMethod::Get).expect("should serialize");
201        assert_eq!(json, "\"GET\"");
202    }
203
204    #[test]
205    fn http_method_deserializes_uppercase() {
206        let method: HttpMethod = serde_json::from_str("\"POST\"").expect("should deserialize");
207        assert_eq!(method, HttpMethod::Post);
208    }
209
210    #[test]
211    fn api_request_serializes() {
212        let req = ApiRequest {
213            method: HttpMethod::Get,
214            path: "/documents/abc123".to_string(),
215            query_params: vec![("limit".to_string(), "10".to_string())],
216            body: None,
217            content_type: None,
218        };
219        let json = serde_json::to_value(&req).expect("should serialize");
220        assert_eq!(json["method"], "GET");
221        assert_eq!(json["path"], "/documents/abc123");
222    }
223
224    #[test]
225    fn api_request_with_json_body_serializes() {
226        let req = ApiRequest {
227            method: HttpMethod::Post,
228            path: "/documents".to_string(),
229            query_params: vec![],
230            body: Some(RequestBody::Json(serde_json::json!({"name": "test"}))),
231            content_type: Some("application/json".to_string()),
232        };
233        let json = serde_json::to_value(&req).expect("should serialize");
234        assert_eq!(json["method"], "POST");
235        assert!(json["body"].is_object());
236    }
237
238    #[test]
239    fn request_body_as_json() {
240        let json_body = RequestBody::Json(serde_json::json!({"key": "value"}));
241        assert!(json_body.as_json().is_some());
242
243        let multipart_body = RequestBody::Multipart(MultipartBody {
244            text_fields: vec![],
245            binary_fields: vec![],
246        });
247        assert!(multipart_body.as_json().is_none());
248    }
249}