xref: /expo/ios/Exponent/Kernel/Api/EXApiV2Client.m (revision 57a8ea27)
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