1// Copyright 2015-present 650 Industries. All rights reserved. 2 3#import "EXApiV2Client.h" 4#import "EXBuildConstants.h" 5#import "EXKernelUtil.h" 6 7NS_ASSUME_NONNULL_BEGIN 8 9NSString * const EXApiErrorDomain = @"www"; 10 11NSString * const EXApiResponseKey = @"EXApiResponse"; 12NSString * const EXApiResultKey = @"EXApiResult"; 13NSString * const EXApiHttpStatusCodeKey = @"EXApiHttpStatusCode"; 14NSString * const EXApiErrorCodeKey = @"EXApiErrorCode"; 15NSString * const EXApiErrorStackKey = @"EXApiHttpStatusCode"; 16 17NSString * const EXApiHttpCacheDirectory = @"kernel-www"; 18 19 20@interface EXApiV2Client () 21 22@property (nonatomic, strong, readonly) NSURLSession *urlSession; 23 24@end 25 26 27@implementation EXApiV2Client 28 29+ (instancetype)sharedClient 30{ 31 static EXApiV2Client *client; 32 static dispatch_once_t once; 33 dispatch_once(&once, ^{ 34 NSURLSessionConfiguration *sessionConfiguration = [self _secureUrlSessionConfiguration]; 35 NSURLSession *session = [NSURLSession sessionWithConfiguration:sessionConfiguration]; 36 client = [[EXApiV2Client alloc] initWithUrlSession:session]; 37 }); 38 return client; 39} 40 41+ (NSURLSessionConfiguration *)_secureUrlSessionConfiguration 42{ 43 NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; 44 configuration.HTTPAdditionalHeaders= @{ 45 @"expo-platform": @"ios", 46 }; 47 48 // Enforce TLS 1.2+ in production 49 configuration.TLSMinimumSupportedProtocol = kTLSProtocol12; 50 51 // Isolate the kernel's HTTP cache to mitigate side-channel attacks 52 NSURLCache *defaultUrlCache = NSURLCache.sharedURLCache; 53 configuration.URLCache = [[NSURLCache alloc] initWithMemoryCapacity:defaultUrlCache.memoryCapacity 54 diskCapacity:defaultUrlCache.diskCapacity 55 diskPath:EXApiHttpCacheDirectory]; 56 57 return configuration; 58} 59 60+ (BOOL)_canSendBodyWithHttpMethod:(NSString *)httpMethod 61{ 62 static NSSet<NSString *> *httpMethodsWithBody; 63 static dispatch_once_t once; 64 dispatch_once(&once, ^{ 65 httpMethodsWithBody = [NSSet setWithObjects:@"POST", @"PUT", @"PATCH", nil]; 66 }); 67 return [httpMethodsWithBody containsObject:httpMethod.uppercaseString]; 68} 69 70- (instancetype)initWithUrlSession:(NSURLSession *)urlSession 71{ 72 if (self = [super init]) { 73 _urlSession = urlSession; 74 } 75 return self; 76} 77 78- (nullable NSURLSessionTask *)callRemoteMethod:(NSString *)methodPath 79 arguments:(nullable NSDictionary *)arguments 80 httpMethod:(NSString *)httpMethod 81 completionHandler:(EXApiV2CompletionHandler)handler 82{ 83 NSURL *apiEndpoint = [EXBuildConstants sharedInstance].apiServerEndpoint; 84 NSURL *remoteMethodUrl = [NSURL URLWithString:methodPath relativeToURL:apiEndpoint].absoluteURL; 85 if (arguments && ![EXApiV2Client _canSendBodyWithHttpMethod:httpMethod]) { 86 remoteMethodUrl = [self _urlFromRemoteMethodUrl:remoteMethodUrl withArguments:arguments]; 87 } 88 89 NSMutableDictionary *headers = [NSMutableDictionary dictionaryWithDictionary:@{ 90 @"accept": @"application/json", 91 }]; 92 NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:remoteMethodUrl]; 93 request.timeoutInterval = 30; 94 request.HTTPMethod = httpMethod; 95 request.HTTPShouldHandleCookies = NO; 96 97 if (arguments && [EXApiV2Client _canSendBodyWithHttpMethod:httpMethod]) { 98 NSError *error = nil; 99 NSData *requestBody = [self _requestBodyForMethod:methodPath arguments:arguments error:&error]; 100 if (!requestBody) { 101 handler(nil, error); 102 return nil; 103 } 104 request.HTTPBody = requestBody; 105 headers[@"content-type"] = @"application/json; charset=utf-8"; 106 } 107 108 request.allHTTPHeaderFields = headers; 109 NSURLSessionTask *task = [_urlSession dataTaskWithRequest:request completionHandler:^(NSData * _Nullable data, 110 NSURLResponse * _Nullable response, 111 NSError * _Nullable error) { 112 // Network errors 113 if (error) { 114 handler(nil, error); 115 return; 116 } 117 118 NSAssert([response isKindOfClass:[NSHTTPURLResponse class]], @"The API response must be an HTTP response"); 119 NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)response; 120 121 // Malformed response errors 122 if (!data) { 123 NSError *responseError = [NSError errorWithDomain:EXApiErrorDomain 124 code:EXApiErrorCodeEmptyResponse 125 userInfo:@{ 126 NSLocalizedDescriptionKey: @"The Expo server's response was empty", 127 EXApiHttpStatusCodeKey: @(httpResponse.statusCode), 128 }]; 129 handler(nil, responseError); 130 return; 131 } 132 133 NSError *jsonError = nil; 134 id object = [NSJSONSerialization JSONObjectWithData:data options:0 error:&jsonError]; 135 if (!object) { 136 NSError *responseError = [NSError errorWithDomain:EXApiErrorDomain 137 code:EXApiErrorCodeMalformedJson 138 userInfo:@{ 139 NSLocalizedDescriptionKey: @"The Expo server's response wasn't valid JSON", 140 NSUnderlyingErrorKey: jsonError, 141 EXApiHttpStatusCodeKey: @(httpResponse.statusCode), 142 }]; 143 handler(nil, responseError); 144 return; 145 } 146 147 if (![object isKindOfClass:[NSDictionary class]]) { 148 NSError *responseError = [NSError errorWithDomain:EXApiErrorDomain 149 code:EXApiErrorCodeMalformedResponse 150 userInfo:@{ 151 NSLocalizedDescriptionKey: @"The Expo server's response wasn't a JSON object", 152 EXApiResponseKey: object, 153 EXApiHttpStatusCodeKey: @(httpResponse.statusCode), 154 }]; 155 handler(nil, responseError); 156 return; 157 } 158 159 // API response (which could have API-level errors) 160 id<NSObject> resultData = nil; 161 NSError *resultError = nil; 162 NSDictionary *resultObject = object; 163 164 if (resultObject[@"data"]) { 165 resultData = resultObject[@"data"]; 166 } 167 168 if ([resultObject[@"errors"] isKindOfClass:[NSArray class]]) { 169 NSArray *errorObjects = resultObject[@"errors"]; 170 if ([errorObjects.firstObject isKindOfClass:[NSDictionary class]]) { 171 resultError = [self _errorFromDictionary:errorObjects.firstObject]; 172 } 173 } 174 175 EXApiV2Result *result = [[EXApiV2Result alloc] initWithData:resultData 176 error:resultError 177 httpStatusCode:httpResponse.statusCode]; 178 handler(result, result.error); 179 }]; 180 181 // The kernel receives higher priority than individual apps 182 task.priority = NSURLSessionTaskPriorityHigh; 183 [task resume]; 184 return task; 185} 186 187- (NSURL *)_urlFromRemoteMethodUrl:(NSURL *)url withArguments:(NSDictionary *)arguments 188{ 189 NSURLComponents *urlComponents = [NSURLComponents componentsWithURL:url 190 resolvingAgainstBaseURL:YES]; 191 NSMutableArray<NSURLQueryItem *> *queryItems = 192 [urlComponents.queryItems mutableCopy] ?: [NSMutableArray arrayWithCapacity:arguments.count]; 193 for (NSString *parameterName in arguments) { 194 NSString *parameterValue = [arguments[parameterName] description]; 195 [queryItems addObject:[NSURLQueryItem queryItemWithName:parameterName value:parameterValue]]; 196 } 197 urlComponents.queryItems = queryItems; 198 return urlComponents.URL; 199} 200 201- (NSData *)_requestBodyForMethod:(NSString *)methodPath arguments:(NSDictionary *)arguments error:(NSError **)error 202{ 203 if (![NSJSONSerialization isValidJSONObject:arguments]) { 204 *error = [NSError errorWithDomain:EXApiErrorDomain 205 code:EXApiErrorCodeMalformedRequestBody 206 userInfo:@{ 207 NSLocalizedDescriptionKey: [NSString stringWithFormat:@"The arguments for the remote API call to \"%@\" cannot be encoded as JSON", methodPath], 208 209 }]; 210 return nil; 211 } 212 213 NSError *jsonError = nil; 214 NSData *jsonData = [NSJSONSerialization dataWithJSONObject:arguments options:0 error:&jsonError]; 215 if (!jsonData) { 216 *error = [NSError errorWithDomain:EXApiErrorDomain 217 code:EXApiErrorCodeMalformedRequestBody 218 userInfo:@{ 219 NSLocalizedDescriptionKey: [NSString stringWithFormat:@"Something went wrong encoding the arguments for the remote API call to \"%@\"", methodPath], 220 NSUnderlyingErrorKey: jsonError, 221 }]; 222 return nil; 223 } 224 225 return jsonData; 226} 227 228- (NSError *)_errorFromDictionary:(NSDictionary *)errorObject 229{ 230 NSMutableDictionary *errorInfo = [NSMutableDictionary dictionary]; 231 232 errorInfo[NSLocalizedDescriptionKey] = errorObject[@"message"] 233 ? [errorObject[@"message"] description] 234 : @"Something went wrong communicating with the Expo server."; 235 236 errorInfo[EXApiErrorCodeKey] = errorObject[@"code"] 237 ? [errorObject[@"code"] description] 238 : @"UNKNOWN"; 239 240 if ([errorObject[@"details"] isKindOfClass:[NSString class]]) { 241 errorInfo[NSLocalizedFailureReasonErrorKey] = errorObject[@"details"]; 242 } 243 244 if ([errorObject[@"stack"] isKindOfClass:[NSString class]]) { 245 errorInfo[EXApiErrorStackKey] = errorObject[@"stack"]; 246 } 247 248 return [NSError errorWithDomain:EXApiErrorDomain code:EXApiErrorCodeApiError userInfo:errorInfo]; 249} 250 251@end 252 253NS_ASSUME_NONNULL_END 254