diff --git a/README.md b/README.md index 0c192da..f34573d 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ fs.oauthResponse(function(error, response){ }); fs.oauthPassword(username, password, function(error, response){ }); // GET -fs.get('/platform/tree/current-person', function(error, response){ }); +fs.get('/platform/users/current', function(error, response){ }); // The SDK defaults the Accept and Content-Type headers to application/x-fs-v1+json // for all /platform/ URLs. But that doesn't work for some endpoints which use @@ -124,7 +124,7 @@ fs.request('/platform/tree/persons/PPPP-PPP', { // The options object is optional. When options are not include, the SDK will // automatically detect that the callback is the second parameter. -// The `method` option defaults to 'GET'. +// The `method` defaults to 'GET'. fs.request('/platform/tree/persons/PPPP-PPP', function(error, response){ }); // Set the access token. This will also save it in a cookie if that behavior @@ -137,6 +137,9 @@ fs.getAccessToken(); // Delete the access token. fs.deleteAccessToken(); +// Redirects are tricky. Read more about them below. +fs.get('/platform/tree/current-person', { followRedirect: true }, function(error, response){ }) + // MIDDLEWARE // // The SDK also supports middleware for modifying requests and responses. @@ -209,23 +212,61 @@ fs.get('/platform/tree/persons/PPPP-PPP', function(error, response){ ### Redirects -The [XMLHttpRequest](https://dvcs.w3.org/hg/xhr/raw-file/tip/Overview.html#infrastructure-for-the-send%28%29-method) -spec states that redirects should be transparently followed. But that leads to -problems with the FamilySearch API because some browsers won't repeat all of the -same options on the second request. For example, if you request JSON from -`/platform/tree/current-person` by setting the `Accept` header to `application/x-fs-v1+json`, -the API will responsd with a 303 redirect to the actual person URL, such as +__TLDR__: Automatically handling redirects is hard thus the SDK defaults to +the platform's behavior. This may or may not be desired. Use request options +to modify the default behavior per request. + +```js +client.get('/platform/tree/current-person', { + expectRedirect: true, + followRedirect: true +}); +``` + +Handling redirects is tricky due to two issues. + +First, browsers are configured to transparently follow 3xx redirects for +[XMLHttpRequest](https://dvcs.w3.org/hg/xhr/raw-file/tip/Overview.html#infrastructure-for-the-send%28%29-method) +requests. However some browsers don't repeat all of the same request options on +the redirect. For example, if you request JSON from `/platform/tree/current-person` +by setting the `Accept` header to `application/x-fs-v1+json`, the API will respond +with a 303 redirect to the actual person URL, such as `/platform/tree/persons/PPPP-PPP`. Then browser then will send a second request to the new URL but some browsers will not copy the `Accept` header from the first request to the second request so the API doesn't know you want JSON for the second -request and will responsd with XML since that's the default format. That causes -problems so the API supports a custom `X-Expect-Override` header which instructs -the browser to respond with a 200 status instead of a 303 so XMLHttpRequest -doesn't detect the redirect. That allows the SDK to detect the redirect and respond -accordingly. This also allows the SDK to track both the original URL and the -effective (final) URL which normally isn't possible with XMLHttpRequest. Responses +request and will respond with XML since that's the default format. Obtaining XML +when you really want JSON is obviously problematic so the API supports a custom +`X-Expect-Override` header which instructs the browser to respond with a 200 +status instead of a 303 so XMLHttpRequest doesn't detect the redirect. +That allows the SDK to detect the redirect and respond accordingly. +This also allows the SDK to track both the original URL and the effective +(final) URL which normally isn't possible with XMLHttpRequest. Responses from redirected requests will have the `redirected` property set to `true`. +Second, we don't always want to follow redirects. For example, if a person +portrait exists the API will respond with a 307 redirect to the image file. +However all we usually want is the URL so that we can add insert the URL into +HTML and have the browser download the image. + +Due to the two issue described above, the SDK defaults to no special handling +of redirects. In node all redirect responses will be returned to the response +callbacks and in the browser all redirects will be transparently followed by +the browser. The default behavior can be modified by setting request options. + +* `expectRedirect`: When `true` the `X-Expect-Override` header will be set on the +request which causes the API to respond with a 200 for redirects. +* `followRedirect`: When `true` the SDK will automatically follow a redirect. + +__It gets worse.__ Redirects are not allowed with CORS requests which require a +preflight OPTIONS request thus you wil always want to set the `expectRedirect` +header to true in the browser. But you can't do that because the API honors the +`X-Expect-Override` header for 304s as well. That is problematic when requesting +persons because your browser will cache the first response then send a +`If-none-match` request the second time which the API would reply to with a 304 +and an empty body but instead sends a 200 with the empty body but the browser +doesn't understand that it's a cached response thus the response is resolved +without a body. That's not what you want. + ### Throttling The SDK will automatically retry throttled requests and obey the throttling diff --git a/src/Request.js b/src/Request.js index 2e29ad0..07b5e85 100644 --- a/src/Request.js +++ b/src/Request.js @@ -6,12 +6,39 @@ * @param {Function} callback */ var Request = function(url, options, callback){ + + // Inititialize and set defaults this.url = url; - this.method = options.method || 'GET'; - this.headers = options.headers ? JSON.parse(JSON.stringify(options.headers)) : {}; - this.body = options.body; - this.retries = options.retries || 0; this.callback = callback || function(){}; + this.method = 'GET'; + this.headers = {}; + this.retries = 0; + this.options = {}; + + // Process request options. We use a for loop so that we can stuff all + // non-standard options into the options object on the reuqest. + var opt; + for(opt in options){ + if(options.hasOwnProperty(opt)){ + switch(opt){ + + case 'method': + case 'body': + case 'retries': + this[opt] = options[opt]; + break; + + case 'headers': + // We copy the headers object so that we don't have to worry about the developer + // and the SDK stepping on each other's toes by modifying the headers object. + this.headers = JSON.parse(JSON.stringify(options.headers)); + break; + + default: + this.options[opt] = options[opt]; + } + } + } }; /** diff --git a/src/middleware/request/disableAutomaticRedirects.js b/src/middleware/request/disableAutomaticRedirects.js index c86dec0..6bbfcf9 100644 --- a/src/middleware/request/disableAutomaticRedirects.js +++ b/src/middleware/request/disableAutomaticRedirects.js @@ -1,6 +1,13 @@ -// Disable automatic redirects +/** + * Disable automatic redirects. Useful client-side so that the browser doesn't + * automatically follow 3xx redirects; that causes problems if the browser + * doesn't replay all request options such as the Accept header. + * + * This middleware is enabled per request by setting the `expectRedirect` request + * option to `true`. + */ module.exports = function(client, request, next){ - if(!request.hasHeader('X-Expect-Override') && request.isPlatform()){ + if(request.options.expectRedirect && !request.hasHeader('X-Expect-Override') && request.isPlatform()){ request.setHeader('X-Expect-Override', '200-ok'); } next(); diff --git a/src/middleware/response/redirect.js b/src/middleware/response/redirect.js index 37fc345..98a4623 100644 --- a/src/middleware/response/redirect.js +++ b/src/middleware/response/redirect.js @@ -1,6 +1,13 @@ +/** + * Automatically follow a redirect. This behavior is optional because you don't + * allways want to follow redirects such as when requesting a person's profile. + * + * This middleware is enabled per request by setting the `followRedirect` request + * option to true. + */ module.exports = function(client, request, response, next){ var location = response.headers['location']; - if(response.statusCode === 200 && location && location !== request.url ){ + if(request.options.followRedirect && location && location !== request.url ){ var originalUrl = request.url; request.url = response.headers['location']; client._execute(request, function(error, response){ diff --git a/src/xhrHandler.js b/src/xhrHandler.js index f7e3d85..34756f1 100644 --- a/src/xhrHandler.js +++ b/src/xhrHandler.js @@ -25,12 +25,14 @@ module.exports = function(request, callback){ xhr.onload = function(){ var response = createResponse(xhr, request); setTimeout(function(){ + console.log('onload'); callback(null, response); }); }; // Attach error handler xhr.onerror = function(error){ + console.log('error'); setTimeout(function(){ callback(error); }); diff --git a/test/browser.js b/test/browser.js index 8d53a5f..09bd85e 100644 --- a/test/browser.js +++ b/test/browser.js @@ -67,4 +67,43 @@ describe('browser', function(){ }); }); + it('redirect', function(done){ + this.timeout(10000); + nockBack('browserOauthPassword.json', function(nockDone){ + client.oauthPassword(sandbox.username, sandbox.password, function(error, response){ + nockDone(); + check(function(error){ + if(error){ + done(error); + } + }, function(){ + assert.isDefined(response); + assert.equal(response.statusCode, 200); + assert.isDefined(response.data); + assert.isDefined(response.data.token); + nockBack('browserRedirect.json', function(nockDone){ + createPerson(client, function(personId){ + client.get('/platform/tree/current-person', { + expectRedirect: true, + followRedirect: true + }, function(error, response){ + nockDone(); + check(done, function(){ + assert.isDefined(response); + assert.equal(response.statusCode, 200); + assert.isDefined(response.data); + assert.isArray(response.data.persons); + assert(response.redirected); + assert.isDefined(response.originalUrl); + assert.isDefined(response.effectiveUrl); + assert(response.originalUrl !== response.effectiveUrl); + }); + }); + }); + }); + }); + }); + }); + }); + }); \ No newline at end of file diff --git a/test/node.js b/test/node.js index 52aa5f2..aca40bd 100644 --- a/test/node.js +++ b/test/node.js @@ -93,7 +93,9 @@ describe('node', function(){ it('redirect', function(done){ nockBack('redirect.json', function(nockDone){ - client.get('/platform/tree/current-person', function(error, response){ + client.get('/platform/tree/current-person', { + followRedirect: true + }, function(error, response){ nockDone(); check(done, function(){ assert.isDefined(response); diff --git a/test/responses/browserOauthPassword.json b/test/responses/browserOauthPassword.json index 381c699..e219154 100644 --- a/test/responses/browserOauthPassword.json +++ b/test/responses/browserOauthPassword.json @@ -6,20 +6,20 @@ "body": "grant_type=password&client_id=a02j000000JBxOxAAL&username=sdktester&password=1234sdkpass", "status": 200, "response": { - "token": "USYSFE9C8BF6A44C254CE62B706FB33215CD_idses-int01.a.fsglobal.net", + "token": "USYS996204AFB923618109AD7D3B316F694D_idses-int02.a.fsglobal.net", "token_type": "family_search", - "access_token": "USYSFE9C8BF6A44C254CE62B706FB33215CD_idses-int01.a.fsglobal.net" + "access_token": "USYS996204AFB923618109AD7D3B316F694D_idses-int02.a.fsglobal.net" }, "headers": { "server": "Apache-Coyote/1.1", "expires": "Tue, 03 Jul 2001 06:00:00 GMT", - "last-modified": "Fri Nov 18 17:38:06 GMT+00:00 2016", + "last-modified": "Tue Nov 29 20:45:25 GMT+00:00 2016", "cache-control": "no-store, no-cache, must-revalidate, max-age=0, post-check=0, pre-check=0", "pragma": "no-cache", "content-type": "application/json;charset=ISO-8859-1", "content-language": "en", "content-length": "185", - "date": "Fri, 18 Nov 2016 17:38:06 GMT", + "date": "Tue, 29 Nov 2016 20:45:24 GMT", "connection": "close", "access-control-allow-methods": "OPTIONS, HEAD, GET, PUT, POST, DELETE", "access-control-allow-headers": "Accept, Accept-Charset, Accept-Encoding, Accept-Language, Accept-Datetime, Authorization, Cache-Control, Connection, Content-Length, Content-Md5, Content-Type, Date, Expect, From, Host, If-Match, If-Modified-Since, If-None-Match, If-Range, If-Unmodified-Since, Location, Origin, Pragma, Range, Referer, SingularityJSHeader, Te, User-Agent, Warning, X-Expect-Override, X-Reason, X-Requested-With, X-FS-Feature-Tag", diff --git a/test/responses/browserRedirect.json b/test/responses/browserRedirect.json new file mode 100644 index 0000000..0ef54ae --- /dev/null +++ b/test/responses/browserRedirect.json @@ -0,0 +1,178 @@ +[ + { + "scope": "https://integration.familysearch.org:443", + "method": "OPTIONS", + "path": "/platform/tree/persons", + "body": "", + "status": 204, + "response": "", + "headers": { + "allow": "OPTIONS,HEAD,POST,GET", + "date": "Tue, 29 Nov 2016 20:46:36 GMT", + "server": "Apache-Coyote/1.1", + "connection": "Close", + "access-control-allow-methods": "OPTIONS, HEAD, GET, PUT, POST, DELETE", + "access-control-allow-headers": "Accept, Accept-Charset, Accept-Encoding, Accept-Language, Accept-Datetime, Authorization, Cache-Control, Connection, Content-Length, Content-Md5, Content-Type, Date, Expect, From, Host, If-Match, If-Modified-Since, If-None-Match, If-Range, If-Unmodified-Since, Location, Origin, Pragma, Range, Referer, SingularityJSHeader, Te, User-Agent, Warning, X-Expect-Override, X-Reason, X-Requested-With, X-FS-Feature-Tag", + "access-control-expose-headers": "Location, Link, Warning, X-Entity-ID, Content-Location, X-Processing-Time, Retry-After, X-FS-Page-Context, Allow", + "access-control-allow-origin": "null", + "access-control-max-age": "604800", + "vary": "Origin" + } + }, + { + "scope": "https://integration.familysearch.org:443", + "method": "POST", + "path": "/platform/tree/persons", + "body": { + "persons": [ + { + "living": true, + "gender": { + "type": "http://gedcomx.org/Male" + }, + "names": [ + { + "type": "http://gedcomx.org/BirthName", + "preferred": true, + "nameForms": [ + { + "fullText": "Jacob", + "parts": [ + { + "value": "Jacob", + "type": "http://gedcomx.org/Given" + } + ] + } + ] + } + ] + } + ] + }, + "status": 201, + "response": "", + "headers": { + "cache-control": "no-cache, no-store, no-transform, must-revalidate, max-age=0", + "content-type": "application/x-fs-v1+json", + "date": "Tue, 29 Nov 2016 20:46:38 GMT", + "link": "; rel=\"ancestry\", ; rel=\"child-relationships\", ; rel=\"descendancy\", ; rel=\"discussion-references\", ; rel=\"matches\", ; rel=\"notes\", ; rel=\"parent-relationships\", ; rel=\"person-with-relationships\", ; rel=\"persons\", ; rel=\"source-references\", ; rel=\"spouse-relationships\"", + "location": "https://integration.familysearch.org/platform/tree/persons/L58P-SC7", + "server": "Apache-Coyote/1.1", + "vary": "Accept, Accept-Language, Accept-Encoding, Expect, Accept-Encoding, Origin", + "x-entity-id": "L58P-SC7", + "x-processing-time": "884", + "x-throttle-millis-left": "1797672", + "x-throttle-millis-used": "2328", + "x-throttle-window-size": "1800000", + "content-length": "0", + "connection": "Close", + "access-control-allow-methods": "OPTIONS, HEAD, GET, PUT, POST, DELETE", + "access-control-allow-headers": "Accept, Accept-Charset, Accept-Encoding, Accept-Language, Accept-Datetime, Authorization, Cache-Control, Connection, Content-Length, Content-Md5, Content-Type, Date, Expect, From, Host, If-Match, If-Modified-Since, If-None-Match, If-Range, If-Unmodified-Since, Location, Origin, Pragma, Range, Referer, SingularityJSHeader, Te, User-Agent, Warning, X-Expect-Override, X-Reason, X-Requested-With, X-FS-Feature-Tag", + "access-control-expose-headers": "Location, Link, Warning, X-Entity-ID, Content-Location, X-Processing-Time, Retry-After, X-FS-Page-Context, Allow", + "access-control-allow-origin": "null", + "access-control-max-age": "604800" + } + }, + { + "scope": "https://integration.familysearch.org:443", + "method": "OPTIONS", + "path": "/platform/tree/current-person", + "body": "", + "status": 204, + "response": "", + "headers": { + "allow": "OPTIONS,HEAD,GET", + "date": "Tue, 29 Nov 2016 20:46:38 GMT", + "server": "Apache-Coyote/1.1", + "connection": "Close", + "access-control-allow-methods": "OPTIONS, HEAD, GET, PUT, POST, DELETE", + "access-control-allow-headers": "Accept, Accept-Charset, Accept-Encoding, Accept-Language, Accept-Datetime, Authorization, Cache-Control, Connection, Content-Length, Content-Md5, Content-Type, Date, Expect, From, Host, If-Match, If-Modified-Since, If-None-Match, If-Range, If-Unmodified-Since, Location, Origin, Pragma, Range, Referer, SingularityJSHeader, Te, User-Agent, Warning, X-Expect-Override, X-Reason, X-Requested-With, X-FS-Feature-Tag", + "access-control-expose-headers": "Location, Link, Warning, X-Entity-ID, Content-Location, X-Processing-Time, Retry-After, X-FS-Page-Context, Allow", + "access-control-allow-origin": "null", + "access-control-max-age": "604800", + "vary": "Origin" + } + }, + { + "scope": "https://integration.familysearch.org:443", + "method": "GET", + "path": "/platform/tree/current-person", + "body": "", + "status": 200, + "response": "", + "headers": { + "cache-control": "no-cache, no-store, no-transform, must-revalidate, max-age=0", + "content-type": "application/x-fs-v1+json", + "date": "Tue, 29 Nov 2016 20:46:38 GMT", + "location": "https://integration.familysearch.org/platform/tree/persons/KW7G-28J", + "server": "Apache-Coyote/1.1", + "vary": "Accept, Accept-Language, Accept-Encoding, Expect, Accept-Encoding, Origin", + "x-processing-time": "172", + "x-throttle-millis-left": "1797504", + "x-throttle-millis-used": "2496", + "x-throttle-window-size": "1800000", + "content-length": "0", + "connection": "Close", + "access-control-allow-methods": "OPTIONS, HEAD, GET, PUT, POST, DELETE", + "access-control-allow-headers": "Accept, Accept-Charset, Accept-Encoding, Accept-Language, Accept-Datetime, Authorization, Cache-Control, Connection, Content-Length, Content-Md5, Content-Type, Date, Expect, From, Host, If-Match, If-Modified-Since, If-None-Match, If-Range, If-Unmodified-Since, Location, Origin, Pragma, Range, Referer, SingularityJSHeader, Te, User-Agent, Warning, X-Expect-Override, X-Reason, X-Requested-With, X-FS-Feature-Tag", + "access-control-expose-headers": "Location, Link, Warning, X-Entity-ID, Content-Location, X-Processing-Time, Retry-After, X-FS-Page-Context, Allow", + "access-control-allow-origin": "null", + "access-control-max-age": "604800" + } + }, + { + "scope": "https://integration.familysearch.org:443", + "method": "OPTIONS", + "path": "/platform/tree/persons/KW7G-28J", + "body": "", + "status": 204, + "response": "", + "headers": { + "allow": "OPTIONS,HEAD,DELETE,POST,GET", + "date": "Tue, 29 Nov 2016 20:46:39 GMT", + "server": "Apache-Coyote/1.1", + "connection": "Close", + "access-control-allow-methods": "OPTIONS, HEAD, GET, PUT, POST, DELETE", + "access-control-allow-headers": "Accept, Accept-Charset, Accept-Encoding, Accept-Language, Accept-Datetime, Authorization, Cache-Control, Connection, Content-Length, Content-Md5, Content-Type, Date, Expect, From, Host, If-Match, If-Modified-Since, If-None-Match, If-Range, If-Unmodified-Since, Location, Origin, Pragma, Range, Referer, SingularityJSHeader, Te, User-Agent, Warning, X-Expect-Override, X-Reason, X-Requested-With, X-FS-Feature-Tag", + "access-control-expose-headers": "Location, Link, Warning, X-Entity-ID, Content-Location, X-Processing-Time, Retry-After, X-FS-Page-Context, Allow", + "access-control-allow-origin": "null", + "access-control-max-age": "604800", + "vary": "Origin" + } + }, + { + "scope": "https://integration.familysearch.org:443", + "method": "GET", + "path": "/platform/tree/persons/KW7G-28J", + "body": "", + "status": 200, + "response": [ + "1f8b0800000000000000", + "ed5a5b73d338147ecfaff09817988d70ec386ed26561804229256d9764a6cc00b3a3d84aa2c5b791e5d04ea7ff7d65cb37f99284342e4c175e4a2cf97ce7ae738e7cd39124d9428149b04fb1e7cad2a1243f9a1c81d3cb8363a00ddfcbdd68878f48e0b941b4fa59ba614fd8336cc59b858dec71e0117a8aaee3b55ef62f5db5f10abb8b68919210250f17c8b510891e72d23971a88d101aaa06e8e9231de8465f0770661960a0f546da6c66c0fe2025cd5e8294123c0b533952626cc5f45cbee4117185ad1114782131510cb8a4d40f0e1505bb142d088c483d9d4307dbd70182c45c3ef5c842f16d48e71e719430607a5120e39f06ca786c8cc1f0eff7193f02f5132e50b629db739b6f971dcfc2738ce2ad6a7f34d48c5e7fa41aa35ea7b457a6d77ece30e377812cd373ae62f6c6d046b9526cec7e0baaea30ed30a8e8892d2d099aefa0094a1052122f51529750721ca6a56d4c996ba553fc7b9b794f491639f03d6684b27c7b152285a8d8c05c427781c01207ccadaedb648123d5b0005d130574afe029c9179c8bbfb2f8ae80bb1e6d57f31ca006d7050ea4e6b2757400395095079f653902316d95831ca482ef110bbbb1a9da64a08052f57ecfb69159936b77e420a717c4dc5411e37771bb1267188d16bf0f8357d1efc1df53889a3c876d8ba03dd9b929c3251815f4a83861f501f3c27d66b902d5cd898e6f6851fc2a242f1a40a1326bf78c8be16a6cef20b24025648a9c88dc2e0593807ee363eb568911d80f2bfa75f3029a8c8fe01fea7d436e778e6d8ac86da1a2ca0b1fe8fb36366340e55f46af5b7c7005e60158a97f342e5c39b6f89cfda6e88a2a4bead845bc881f9f5610b70110c8d8b6f73da6727e313d393f9b748fdf4cbb17e793a9201ca63697ee22d691348ed5df9cfd012b2f1159c1d61d44c0a9962184e23934db3d0d1de478a43e35431255e0ad66e604a2b62cc5165b8b0a772216a735b5796457562fb2fdbc97da8e3948be1d2a863aeaf515fd503dccd286f455e0c3850e127ab4bc91327b9a0a87c3213007fa00e8fde101181d200dcc58d93de8f7f5414f337f37522563bdc2842ecf985273cdf8cca910219c72dec2fe427dd65696aef6597975cde47dcba04a8e14d55fa16d4f598e8cd99e1c9d4a53d628b0bebda0771626b4f2e2062d1fe3153bf7055bafa01da214a668e1eda94e42e2162d57a29bb09e9396be16c4487570e2cebd1a6958fae5e38a14b862af95aabc0989c718c4a600d211ff97c1ca59fafc9c3db270c0ec2e543d31674dfa2fcc51e4f20c608e021ff2e9ce073e82c9833dab84ce426796bcafca750558fd86b4667e195cc459b2e2383c79aaeb13c6a3d220a9b4e7a4346daa0d7b0ea4ed21335532eee52730fe70b681b764534d743d387518e0d5476d933af8a6aa3af2602b38cfeba805d8cd777694e10c8cb5cb4d32f04df7aedf0856df8637bd9eb762d3f6b90d3fabe6b24e6659f6bacd6bc525f66ba7c6e3f1f83df8347eb7766a9ce698b589feb517fa59ae4b5a35c15d36c9bb5ed65b81b0d64cf84ee77829bbaccf2ccdd3d01a26f7566a88b9addc1cabad603675c7df3d07b6d38f3749d9165e9384c500da23b019070b10c253c962516c6e3a85734b085b433b5d1bb6daff276cc55370fd09f893c25638831f6cd83648f9e0c3368ac5cd61fb96ed3d06c79f2eb7894c5ec4f36a6cdbf0bc9bf1c43a6c7d0db67560ef9a31ee373cd78f9cdb094fb1a4fda151565da31b7bca4bd7e27e137c2c85c04efe1e9104d0b5403277abba3e77e7b527d5afeef2fa2697d77fbbfc3e5d5e7f602e9ff45adcc047a59ba272c355fea4271afcccbc90d63a8ecc22c3f75cc6c6f95cd049f94ba17d5cf1a676485d75ba2e50f94d5cf28630e5d555d5186a3d63c82ad0647d150ddb134ed5bed1ef8d0ef4a131548703f193a4b2f797a4bcbb3726d76b4af13a4f295aa4d6314d4c61c59e11c3907f412517c6a685d1e617f96dcc8834652aee7e91250b52388301ea4acff073be3689997ca6e0e7d2e3063f67a452fd4ada409a209fa268fa27693d55ef4a9a7a38d09f7425e623e45a62524af944f2f1c5c99174978b8d277f4ad94503839f5d4b2b48b01706527c51f034719c6c521adf9b95d5240c91a5e2c0377ff1a7dfdc14e2d86c4e2a4d2314e1809b43ba2c7d45f7936b34c76b9ba31f3d42cd74c6d8f6015a15bdadb3ac30496d17f2f2feda9d1f3a0b2b9faadc47d5528cdeceed7f2150275e462b0000" + ], + "headers": { + "allow": "OPTIONS, HEAD, GET, POST, DELETE", + "cache-control": "no-transform, must-revalidate, max-age=0", + "content-encoding": "gzip", + "content-location": "/tree/persons/KW7G-28J", + "content-type": "application/x-fs-v1+json", + "date": "Tue, 29 Nov 2016 20:46:39 GMT", + "etag": "\"136309748681850000-gzip\"", + "last-modified": "Thu, 25 Sep 2014 21:54:28 GMT", + "server": "Apache-Coyote/1.1", + "vary": "Accept, Accept-Language, Accept-Encoding, Expect, Accept-Encoding, Origin", + "x-processing-time": "172", + "x-throttle-millis-left": "1797336", + "x-throttle-millis-used": "2664", + "x-throttle-window-size": "1800000", + "transfer-encoding": "chunked", + "connection": "Close", + "access-control-allow-methods": "OPTIONS, HEAD, GET, PUT, POST, DELETE", + "access-control-allow-headers": "Accept, Accept-Charset, Accept-Encoding, Accept-Language, Accept-Datetime, Authorization, Cache-Control, Connection, Content-Length, Content-Md5, Content-Type, Date, Expect, From, Host, If-Match, If-Modified-Since, If-None-Match, If-Range, If-Unmodified-Since, Location, Origin, Pragma, Range, Referer, SingularityJSHeader, Te, User-Agent, Warning, X-Expect-Override, X-Reason, X-Requested-With, X-FS-Feature-Tag", + "access-control-expose-headers": "Location, Link, Warning, X-Entity-ID, Content-Location, X-Processing-Time, Retry-After, X-FS-Page-Context, Allow", + "access-control-allow-origin": "null", + "access-control-max-age": "604800" + } + } +] \ No newline at end of file