Git · dadoum.zone
Repositories
Help
Report an Issue
apple-crates
Code
Commits
Branches
Tags
Search
Tree:
a74924e
Branches
Tags
main
apple-crates
xcode
src
v1.rs
Refactor and unify a lot of things for another project and implement airdrop support
Dadoum
commited
a74924e
at 2026-10-25 22:15:58
v1.rs
Blame
History
Raw
use crate::{TeamID, XCODE_VERSION, XcodeError, XcodeSession, XcodeToken}; use adi::proxy::ADIResult; use base64::Engine; use base64::prelude::BASE64_STANDARD; use grandslam::AppToken; use reqwest::{Method, RequestBuilder, Url}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::borrow::Cow; const BASE_URL: &str = "https://developerservices2.apple.com/services/v1/"; const CONTENT_TYPE: &str = "application/vnd.api+json"; /// A request to Xcode's flavour of the App Store Connect API. pub trait DeveloperAPIRequest: Serialize { type Result; const METHOD: Method; fn path(&self) -> Cow<'static, str>; fn parse_response(body: &[u8]) -> Self::Result; } #[derive(Debug, Clone, Default, Serialize)] pub struct Page { #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option<u32>, #[serde(skip_serializing_if = "Option::is_none")] pub cursor: Option<String>, } #[derive(Debug, Deserialize)] pub struct PagedDocumentLinks { pub next: Option<String>, } impl PagedDocumentLinks { pub fn next_page(&self) -> Option<Page> { let next = Url::parse(BASE_URL) .ok()? .join(self.next.as_deref()?) .ok()?; let parameter = |name: &str| { next.query_pairs() .find(|(key, _)| key == name) .map(|(_, value)| value.into_owned()) }; // The cursor is ignored without its limit, even when I never sent one. Some(Page { limit: parameter("limit").and_then(|limit| limit.parse().ok()), cursor: Some(parameter("cursor")?), }) } } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ResourceType { #[serde(rename = "apps")] Apps, #[serde(rename = "bundleIds")] BundleIDs, #[serde(rename = "bundleIdCapabilities")] BundleIDCapabilities, #[serde(rename = "capabilities")] Capabilities, #[serde(rename = "certificates")] Certificates, #[serde(rename = "devices")] Devices, #[serde(rename = "passTypeIds")] PassTypeIDs, #[serde(rename = "profiles")] Profiles, #[serde(untagged)] Other(String), } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ResourceIdentifier<I = String> { #[serde(rename = "type")] pub kind: ResourceType, pub id: I, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Relationship<T> { #[serde(default)] pub data: T, } #[derive(Debug, Clone, Deserialize)] pub struct APIError { pub status: String, pub code: String, pub title: String, pub detail: String, } #[derive(Debug, Default, Deserialize)] struct ErrorResponse { #[serde(default)] errors: Vec<APIError>, } #[derive(Serialize)] struct Document<T> { data: T, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct QueryBody { url_encoded_query_params: String, } #[derive(Serialize)] struct TeamQuery<'a, T> { #[serde(rename = "teamId")] team_id: &'a TeamID, #[serde(flatten)] request: &'a T, } fn comma_separated<S: Serializer, T: Serialize>( values: &[T], serializer: S, ) -> Result<S::Ok, S::Error> { let values = values .iter() .map(|value| match serde_json::to_value(value) { Ok(serde_json::Value::String(value)) => Ok(value), _ => Err(serde::ser::Error::custom("Query values must be strings.")), }) .collect::<Result<Vec<_>, _>>()?; serializer.serialize_str(&values.join(",")) } fn null_as_default<'de, D: Deserializer<'de>, T: Default + Deserialize<'de>>( deserializer: D, ) -> Result<T, D::Error> { Ok(Option::deserialize(deserializer)?.unwrap_or_default()) } fn base64_content<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<u8>, D::Error> { let content: Option<String> = Option::deserialize(deserializer)?; BASE64_STANDARD .decode(content.unwrap_or_default()) .map_err(serde::de::Error::custom) } impl XcodeSession<'_, '_> { fn developer_api_request_builder( &self, method: Method, url: &str, ) -> ADIResult<RequestBuilder> { let locale = sys_locale::get_locale().unwrap_or_else(|| String::from("en-US")); Ok(self .account_request_builder(method, url)? .header("Accept", CONTENT_TYPE) .header("Accept-Language", locale) .header("Content-Type", CONTENT_TYPE) .header("X-Apple-App-Info", XcodeToken::APP_TOKEN_IDENTIFIER) .header("X-Apple-GS-Token", self.http_session.token.token.as_ref()) .header("X-Xcode-Version", XCODE_VERSION)) } pub async fn perform_developer_api_request<T: DeveloperAPIRequest>( &self, team_id: &TeamID, request: &T, ) -> Result<T::Result, XcodeError> { let url = format!("{BASE_URL}{}", request.path()); let request_builder = if T::METHOD == Method::GET || T::METHOD == Method::DELETE { // Xcode tunnels these through POST, with the query in the body. let query = serde_urlencoded::to_string(TeamQuery { team_id, request }) .expect("Serialization should never fail."); let body = serde_json::to_vec(&QueryBody { url_encoded_query_params: query, }) .expect("Serialization should never fail."); self.developer_api_request_builder(Method::POST, &url)? .header("X-HTTP-Method-Override", T::METHOD.as_str()) .body(body) } else { let mut data = serde_json::to_value(request).expect("Serialization should never fail."); data["attributes"]["teamId"] = team_id.as_str().into(); let body = serde_json::to_vec(&Document { data }).expect("Serialization should never fail."); self.developer_api_request_builder(T::METHOD, &url)? .body(body) }; let response = request_builder.send().await?; let status = response.status(); let response = response.bytes().await?; if status.is_success() { Ok(T::parse_response(&response)) } else { // Apple explains errors in a JSON:API document, but the status is enough without one. let errors = serde_json::from_slice::<ErrorResponse>(&response) .unwrap_or_default() .errors; Err(XcodeError::DeveloperAPI { status, errors }) } } } macro_rules! impl_developer_api_request { ($name: ty, DELETE, |$request: ident| $path: expr) => { impl $crate::v1::DeveloperAPIRequest for $name { type Result = (); const METHOD: reqwest::Method = reqwest::Method::DELETE; fn path(&self) -> std::borrow::Cow<'static, str> { let $request = self; std::borrow::Cow::Owned($path) } fn parse_response(_: &[u8]) {} } }; ($name: ty, $method: ident, $path: literal, $result: ty) => { impl $crate::v1::DeveloperAPIRequest for $name { type Result = Result<$result, serde_json::Error>; const METHOD: reqwest::Method = reqwest::Method::$method; fn path(&self) -> std::borrow::Cow<'static, str> { std::borrow::Cow::Borrowed($path) } fn parse_response(body: &[u8]) -> Result<$result, serde_json::Error> { serde_json::from_slice(body) } } }; ($name: ty, $method: ident, |$request: ident| $path: expr, $result: ty) => { impl $crate::v1::DeveloperAPIRequest for $name { type Result = Result<$result, serde_json::Error>; const METHOD: reqwest::Method = reqwest::Method::$method; fn path(&self) -> std::borrow::Cow<'static, str> { let $request = self; std::borrow::Cow::Owned($path) } fn parse_response(body: &[u8]) -> Result<$result, serde_json::Error> { serde_json::from_slice(body) } } }; } mod bundle_id_capabilities; mod bundle_ids; mod certificates; pub use bundle_id_capabilities::*; pub use bundle_ids::*; pub use certificates::*; #[cfg(test)] mod tests { use super::*; #[test] fn next_page_carries_the_limit() { let links: PagedDocumentLinks = serde_json::from_str( r#"{ "self": "https://api.appstoreconnect.apple.com/v1/certificates", "next": "https://api.appstoreconnect.apple.com/v1/certificates?cursor=Mg.ANi1Ck4&limit=2" }"#, ) .unwrap(); let page = links.next_page().unwrap(); assert_eq!(page.cursor.as_deref(), Some("Mg.ANi1Ck4")); assert_eq!(page.limit, Some(2)); let links: PagedDocumentLinks = serde_json::from_str( r#"{ "self": "https://api.appstoreconnect.apple.com/v1/certificates" }"#, ) .unwrap(); assert!(links.next_page().is_none()); } #[test] fn queries_carry_the_team() { let request = ListCertificatesRequest { filter_certificate_type: vec![ CertificateType::Development, CertificateType::IOSDevelopment, ], page: Page { limit: Some(200), cursor: None, }, }; let team_id = TeamID::from("ABCDE12345"); let query = serde_urlencoded::to_string(TeamQuery { team_id: &team_id, request: &request, }) .unwrap(); assert_eq!( query, "teamId=ABCDE12345&filter%5BcertificateType%5D=DEVELOPMENT%2CIOS_DEVELOPMENT&limit=200" ); let request = DeleteCertificateRequest { certificate_id: String::from("Z6V8DWZ3R5"), }; let query = serde_urlencoded::to_string(TeamQuery { team_id: &team_id, request: &request, }) .unwrap(); assert_eq!(query, "teamId=ABCDE12345"); assert_eq!(request.path(), "certificates/Z6V8DWZ3R5"); } #[test] fn errors_are_decoded() { let response: ErrorResponse = serde_json::from_str( r#"{ "errors": [{ "id": "4d2a6b1c-0f2b-4a39-9c43-6a1c2b8f0e11", "status": "409", "code": "ENTITY_ERROR.ATTRIBUTE.INVALID", "title": "An attribute value has invalid characters.", "detail": "There are no devices registered for this team.", "source": { "pointer": "/data/attributes/udid" } }] }"#, ) .unwrap(); assert_eq!(response.errors[0].status, "409"); assert_eq!( response.errors[0].detail, "There are no devices registered for this team." ); } }