Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

amp-geo: Fall back to API for country #26407

Merged
merged 8 commits into from
Feb 28, 2020
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build-system/externs/amp.extern.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,10 @@ AmpConfigType.prototype.test;
AmpConfigType.prototype.spt;
/* @public {boolean|undefined} */
AmpConfigType.prototype.esm;
/* @public {string} */
AmpConfigType.prototype.geoApi;
/* @public {string} */
AmpConfigType.prototype.geoApiUrl;

/** @type {!AmpConfigType} */
window.AMP_CONFIG;
Expand Down
124 changes: 122 additions & 2 deletions extensions/amp-geo/0.1/amp-geo.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,14 @@ import {Services} from '../../../src/services';
import {ampGeoPresets} from './amp-geo-presets';

import {GEO_IN_GROUP} from './amp-geo-in-group';
import {dev, userAssert} from '../../../src/log';
import {dev, user, userAssert} from '../../../src/log';
import {getMode} from '../../../src/mode';
import {isArray, isObject} from '../../../src/types';
import {isCanary} from '../../../src/experiments';
import {isJsonScriptTag} from '../../../src/dom';
import {isProxyOrigin, isSecureUrlDeprecated} from '../../../src/url';
import {tryParseJson} from '../../../src/json';
import {urls} from '../../../src/config';

/** @const */
const TAG = 'amp-geo';
Expand All @@ -71,6 +73,7 @@ const GROUP_PREFIX = 'amp-geo-group-';
const PRE_RENDER_REGEX = new RegExp(`${COUNTRY_PREFIX}(\\w+)`);
const GEO_ID = 'ampGeo';
const SERVICE_TAG = 'geo';
const API_TIMEOUT = 60; // Seconds

/**
* Operating Mode
Expand All @@ -80,6 +83,7 @@ const mode = {
GEO_HOT_PATCH: 0, // Default mode, geo is patched by GFE when js is served
GEO_PRERENDER: 1, // We've been prerendered by an AMP Cache or publisher CMS
GEO_OVERRIDE: 2, // We've been overriden in test by #amp-geo=xx
GEO_API: 3, // Query API when cache patching unavailable
};

/**
Expand Down Expand Up @@ -161,6 +165,7 @@ export class AmpGeo extends AMP.BaseElement {
/**
* findCountry_, sets this.country_ and this.mode_
* @param {!../../../src/service/ampdoc-impl.AmpDoc} ampdoc
* @return {Promise}
*/
findCountry_(ampdoc) {
// Flag to see if we've been pre-rendered with a country
Expand Down Expand Up @@ -198,6 +203,9 @@ export class AmpGeo extends AMP.BaseElement {
// We have a valid 2 letter ISO country
this.mode_ = mode.GEO_HOT_PATCH;
this.country_ = trimmedCountryMatch[1];
} else if (trimmedCountryMatch[0] === '' && urls.geoApi) {
// We were not patched, but an API is available
this.mode_ = mode.GEO_API;
} else if (trimmedCountryMatch[0] === '' && !getMode(this.win).localDev) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd prefer to handle one mode in one if statement block. Can we please change the logic to

else if (...) {
  this.mode_ = mode.GEO_HOT_PATCH;
} else if (trimmedCountryMatch[0] === '' && urls.geoApi) {
  this.mode_ = mode.GEO_API;
} else if (!getMode(this.win).localDev) {
  this.error_ = true;
}
....

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

// We were not patched, if we're not in dev this is an error
// and we leave the country at the default 'unknown'
Expand All @@ -207,6 +215,115 @@ export class AmpGeo extends AMP.BaseElement {
'GEONOTPATCHED: amp-geo served unpatched, ISO country not set'
);
}

return this.mode_ !== mode.GEO_API
? Promise.resolve()
: this.fetchCountry_().then(country => {
if (country) {
this.country_ = country;
} else {
// if API request fails, leave the country at the default 'unknown'
this.error_ = true;
dev().error(
TAG,
'GEONOTPATCHED: amp-geo served unpatched and API response not valid, ISO country not set'
);
}
});
}

/**
* Ensure API URL definition is usable and cast its type
* @param {*} url
* @return {?string}
*/
validateApiUrl(url) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: let's make it a private method please. Thanks

if (typeof url !== 'string') {
user().error(TAG, 'geoApiUrl must be a string URL');
return null;
}

if (!isSecureUrlDeprecated(url)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm the function name tells me that it's deprecated : )
#isSecureUrlDeprecated() doesn't handle the relative url case //, but #assertHttpsUrl() does.
The elementContext and sourceName makes the #assertHttpsUrl() looks like it's tied to AMP elements. But they are used only as error message. A better name is probably context instead of elementContext. What about

assertHttpsUrl(url, 'geoApiUrl', 'AMP_CONFIG urls')

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not convinced assertHttpsUrl() is the right choice. It's a wrapper for isSecureUrlDeprecated(url) || /^(\/\/)/.test(url). Given the privacy implications of user location, do you really want to allow relative protocol? It seems more reasonable to directly make use of isSecureUrlDeprecated which requires HTTPS even if an AMP page is served from HTTP.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason it's deprecated is because it doesn't work properly in the different ampdoc modes. Use Services.urlFor's isSecure() instead.

user().error(TAG, 'geoApiUrl must be secure (https)');
return null;
}

if (isProxyOrigin(url)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ProxyOrigin doesn't work the same reason any random url doesn't work. I don't think we need a special check for this. Let me know if you think differently.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Admittedly, there's not much reason for this check. It can be removed. I will wait for a final decision on isSecureUrlDeprecated/assertHttpsUrl before pushing a new change.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's needed.

user().error(TAG, 'geoApiUrl cannot point to the AMP project CDN');
return null;
}

return url;
}

/**
* Fetch country from API defined in config.urls
*
* JSON schema of Geo API response - version 0.1:
* {
* "$schema": "http://json-schema.org/draft-07/schema#",
* "type": "object",
* "properties": {
* "country": {
* "type": "string",
* "title": "ISO 3166-1 alpha-2 (case insensitive) country code of client request",
* "default": "",
* "pattern": "^[a-zA-Z]{2}$"
* }
* },
* "required": [
* "country"
* ]
* }
*
* Sample response:
* {
* "country": "de"
* }
*
* @return {Promise<?string>}
*/
fetchCountry_() {
const url = this.validateApiUrl(urls.geoApi);
if (!url) {
return Promise.resolve(null);
}

user().info(
TAG,
'API request is being used for country, this may result in FOUC'
);

return Services.timerFor(this.win)
.timeoutPromise(
API_TIMEOUT * 1000,
Services.xhrFor(this.win)
.fetchJson(url, {
mode: 'cors',
method: 'GET',
credentials: 'omit',
})
.then(res => res.json())
.then(json => {
if (!/^[a-z]{2}$/i.test(json['country'])) {
user().error(
TAG,
'Invalid API response, expected schema not matched for property "country"'
);
return null;
}
return json['country'].toLowerCase();
})
.catch(reason => {
user().error(TAG, 'XHR country request failed', reason);
return null;
}),
`Timeout (${API_TIMEOUT} sec) reached waiting for API response`
)
.catch(error => {
user().error(TAG, error);
return null;
});
}

/**
Expand Down Expand Up @@ -306,7 +423,9 @@ export class AmpGeo extends AMP.BaseElement {
.whenReady()
.then(() => ampdoc.waitForBodyOpen())
.then(body => {
self.findCountry_(ampdoc);
return self.findCountry_(ampdoc).then(() => body);
})
.then(body => {
self.matchCountryGroups_(config);

let classesToRemove = [];
Expand All @@ -316,6 +435,7 @@ export class AmpGeo extends AMP.BaseElement {
classesToRemove = self.clearPreRender_(body);
// Intentionally fall through.
case mode.GEO_HOT_PATCH:
case mode.GEO_API:
// Build the AMP State, add classes
states.ISOCountry = self.country_;

Expand Down
80 changes: 80 additions & 0 deletions extensions/amp-geo/0.1/test/test-amp-geo.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import {AmpGeo} from '../amp-geo';
import {GEO_IN_GROUP} from '../amp-geo-in-group';
import {Services} from '../../../../src/services';
import {urls} from '../../../../src/config';
import {user} from '../../../../src/log';
import {vsyncForTesting} from '../../../../src/service/vsync-impl';

Expand Down Expand Up @@ -63,6 +64,7 @@ describes.realWin(
let geo;
let el;
let userErrorStub;
let xhr;

beforeEach(() => {
userErrorStub = env.sandbox.stub(user(), 'error');
Expand All @@ -77,6 +79,10 @@ describes.realWin(
vsync.schedule_ = () => {
vsync.runScheduledTasks_();
};
xhr = {
fetchJson: env.sandbox.stub(),
};
env.sandbox.stub(Services, 'xhrFor').returns(xhr);

geo = new AmpGeo(el);
});
Expand Down Expand Up @@ -386,6 +392,80 @@ describes.realWin(
});
});

it('should recognize country if API has valid schema', () => {
env.sandbox.stub(win.__AMP_MODE, 'localDev').value(false);
env.sandbox.stub(urls, 'geoApi').value('/geoapi');
xhr.fetchJson.resolves({
json: () => Promise.resolve(JSON.parse('{"country": "ca", "x": "y"}')),
});
addConfigElement('script');

geo.buildCallback();
return Services.geoForDocOrNull(el).then(geo => {
expect(userErrorStub).to.not.be.called;
expect(geo.ISOCountry).to.equal('ca');
});
});

it('should not recognize country if API has invalid schema', () => {
expectAsyncConsoleError(/GEONOTPATCHED/);
env.sandbox.stub(win.__AMP_MODE, 'localDev').value(false);
env.sandbox.stub(urls, 'geoApi').value('/geoapi');
xhr.fetchJson.resolves({
json: () => Promise.resolve(JSON.parse('{"country": "abc"}')),
});
addConfigElement('script');

geo.buildCallback();
return Services.geoForDocOrNull(el).then(geo => {
expect(userErrorStub).to.be.called;
expect(geo.ISOCountry).to.equal('unknown');
});
});

it('should not recognize country if API unreachable', () => {
expectAsyncConsoleError(/GEONOTPATCHED/);
env.sandbox.stub(win.__AMP_MODE, 'localDev').value(false);
env.sandbox.stub(urls, 'geoApi').value('/geoapi');
xhr.fetchJson.rejects({status: 404});
addConfigElement('script');

geo.buildCallback();
return Services.geoForDocOrNull(el).then(geo => {
expect(userErrorStub).to.be.called;
expect(geo.ISOCountry).to.equal('unknown');
});
});

it('should not recognize country if API times out', () => {
expectAsyncConsoleError(/GEONOTPATCHED/);
env.sandbox.stub(win.__AMP_MODE, 'localDev').value(false);
env.sandbox.stub(urls, 'geoApi').value('/geoapi');
env.sandbox.stub(Services, 'timerFor').returns({
timeoutPromise: function(delay, racePromise, msg) {
return Promise.race([
racePromise,
Promise.reject(user().createError(msg)),
]);
},
});
xhr.fetchJson.resolves({
json: () =>
new Promise(res => {
setTimeout(() => {
res(JSON.parse('{"country": "ca"}'));
}, 10);
}),
});
addConfigElement('script');

geo.buildCallback();
return Services.geoForDocOrNull(el).then(geo => {
expect(userErrorStub).to.be.called;
expect(geo.ISOCountry).to.equal('unknown');
});
});

it('should throw if it has multiple script child elements', () => {
expect(() => {
addConfigElement('script');
Expand Down
2 changes: 2 additions & 0 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export const urls = {
/(^|\.)google\.(com?|[a-z]{2}|com?\.[a-z]{2}|cat)$/,
/(^|\.)gmail\.(com|dev)$/,
],
// Optional fallback API if amp-geo is left unpatched
geoApi: env['geoApiUrl'],
};

export const config = {
Expand Down
2 changes: 2 additions & 0 deletions test/integration/test-configuration.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe.configure().run('Configuration', function() {
config.thirdPartyUrl = 'http://bar.baz.com';
config.thirdPartyFrameRegex = /a-website\.com/;
config.errorReportingUrl = 'http://error.foo.com';
config.geoApiUrl = 'http://geo.bar.com';

return fixture.awaitEvent(AmpEvents.LOAD_START, 1).then(() => {
expect(fixture.win.AMP.config.urls.cdn).to.equal(config.cdnUrl);
Expand All @@ -47,6 +48,7 @@ describe.configure().run('Configuration', function() {
expect(fixture.win.AMP.config.urls.errorReporting).to.equal(
config.errorReportingUrl
);
expect(fixture.win.AMP.config.urls.geoApi).to.equal(config.geoApiUrl);
});
});
});