Skip to content

Commit

Permalink
Implementation.
Browse files Browse the repository at this point in the history
  • Loading branch information
lannka committed Sep 22, 2017
1 parent 49ec17d commit 92cb90b
Show file tree
Hide file tree
Showing 6 changed files with 675 additions and 1 deletion.
3 changes: 3 additions & 0 deletions .gitignore
@@ -0,0 +1,3 @@
node_modules
.idea
test.tmp.js
73 changes: 72 additions & 1 deletion README.md
@@ -1,4 +1,75 @@
# Google AMP CID API Library
# Google AMP Client ID Library

## Introduction
TODO: add introduction

## Usage

##### Use the compiled binary served on Google CDN
Add the script tag into your HTML header.
```html
<head>
...
<script async src="https://ampcid.google.com/googleampcidapi.js"></script>
</head>
```

In your Javascript code, wait for the library load and use it:
```js
function ampCidApiOnload(callback) {
(self.googleAmpCidApiOnload = self.googleAmpCidApiOnload || []).push(callback);
}

ampCidApiOnload(function(api) {
api.getScopedCid('scope-abc', 'YOUR_API_KEY', function(err, cid) {
if (err) {
alert('Error: ' + err);
} else {
alert('Client ID:' + cid);
}
});
});
```

##### Copy the code into your own project
Using the binary served from CDN is the recommended way, which saves you from
version update.

But if you prefer not to load the extra binary, you can compile the the code
(only one file) into your own project.

## Specification

#### Methods
##### getScopedCid(scope, apiKey, callback)
- `scope`: The scope of the client ID. You can get different client IDs for the same user client by applying different `scope`.
- `apiKey`: The API key to be used for the request. You can apply for your own API key [here](). TODO: add link to the console.
- `callback`: The callback is a function taking 2 params: `function(err, cid)`.
- `err`: an `Error` object if there is any error, otherwise `null`.
- `cid`: the client ID string returned from the server.

#### Client ID values
The value of the returned client ID can be one of the following:
- A string starting with `amp-` followed by URL safe Base64 characters. e.g:
```
amp-UaFdEOQkTib3XGbPVGAJt0OQV8_1Hpmp8EsQOM5EySjmiK9UCs7yTCt219Fz2gER
```
- `null`. The client ID was not found.
- `undefined`. An error occured, check error message.
- `'$OPT_OUT'`. The client has opted out client ID tracking.

#### Cookie usage
The library uses cookie `AMP_TOKEN` to store information. It serves 2 purposes:
1. To persist a security token received from the API server which is to be used for exchanging CID next time.
2. To act as a lock so that no concurrent requests being sent.


## Run test
```
$ npm install
$ npm run test
```

## Disclaimer
This is not an official Google product.
42 changes: 42 additions & 0 deletions example.html
@@ -0,0 +1,42 @@
<!--
Copyright 2017 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>AMP Client ID library example</title>
<script async src="index.js"></script>
</head>
<body>
AMP Client ID library example
<script>
function ampCidApiOnload(callback) {
(self.googleAmpCidApiOnload = self.googleAmpCidApiOnload || []).push(callback);
}

ampCidApiOnload(function(api) {
api.getScopedCid('scope-abc', 'YOUR_APIKEY_HERE', function(err, cid) {
if (err) {
alert(err);
} else {
alert('Client ID:' + cid);
}
});
});
</script>
</body>
</html>
232 changes: 232 additions & 0 deletions index.js
@@ -0,0 +1,232 @@
/**
* Copyright 2017 Google Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

var GOOGLE_API_URL = 'https://ampcid.google.com/v1/publisher:getClientId?key=';
var AMP_TOKEN = 'AMP_TOKEN';

var TokenStatus = {
RETRIEVING: '$RETRIEVING',
OPT_OUT: '$OPT_OUT',
NOT_FOUND: '$NOT_FOUND',
ERROR: '$ERROR'
};

var TIMEOUT = 30000;
var HOUR = 60 * 60 * 1000;
var DAY = 24 * HOUR;
var YEAR = 365 * DAY;

var PROXY_ORIGIN_URL_REGEX =
/^https:\/\/([a-zA-Z0-9_-]+\.)?cdn\.ampproject\.org/;

var scopedCidCallbacks = {};

function getScopedCid(scope, apiKey, callback) {
if (scopedCidCallbacks[scope]) {
scopedCidCallbacks[scope].push(callback);
return;
}
scopedCidCallbacks[scope] = [callback];
var cb = wrapCallbacks(scopedCidCallbacks[scope]);
var token;
// Block the request if a previous request is on flight
// Poll every 200ms. Longer interval means longer latency for the 2nd CID.
poll(200, function () {
token = getCookie(AMP_TOKEN);
return token !== TokenStatus.RETRIEVING;
}, function () {
// If the page referrer is proxy origin, we force to use API even the
// token indicates a previous fetch returned nothing
var forceFetch =
token === TokenStatus.NOT_FOUND && isReferrerProxyOrigin();

// Token is in a special state
if (!forceFetch && isStatusToken(token)) {
if (token === TokenStatus.OPT_OUT) {
cb(null, TokenStatus.OPT_OUT);
} else if (token === TokenStatus.NOT_FOUND) {
cb(null, null);
} else if (token === TokenStatus.ERROR) {
cb(new Error('There was an error in previous request. Try later.'));
} else {
cb(new Error('Invalid token state: ' + token));
}
return;
}

if (!token || isStatusToken(token)) {
persistToken(TokenStatus.RETRIEVING, TIMEOUT);
}
fetchCid(GOOGLE_API_URL + apiKey, scope, token, cb)
});
}

function fetchCid(url, scope, token, callback) {
var payload = {'originScope': scope};
if (token) {
payload['securityToken'] = token;
}
fetchJson(url, payload, TIMEOUT, function (err, res) {
if (err) {
persistToken(TokenStatus.ERROR, TIMEOUT);
callback(err);
} else if (res['optOut']) {
persistToken(TokenStatus.OPT_OUT, YEAR);
callback(null, TokenStatus.OPT_OUT);
} else if (res['clientId']) {
persistToken(res['securityToken'], YEAR);
callback(null, res['clientId']);
} else {
persistToken(TokenStatus.NOT_FOUND, HOUR);
callback(null, null);
}
});
}

function wrapCallbacks(callbacks) {
return function () {
var args = arguments;
callbacks.forEach(function (callback) {
callback.apply(null, args);
})
};
}

function persistToken(tokenValue, expires) {
if (tokenValue) {
setCookie(AMP_TOKEN, tokenValue, Date.now() + expires);
}
}

function isReferrerProxyOrigin() {
return PROXY_ORIGIN_URL_REGEX.test(self.document.referrer);
}

function isStatusToken(token) {
return token && token[0] === '$';
}

function poll(delay, predicate, callback) {
var interval = self.setInterval(function () {
if (predicate()) {
self.clearInterval(interval);
callback();
}
}, delay);
}

function getCookie(name) {
var cookies = self.document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = cookies[i].trim();
var values = cookie.split('=', 2);
if (values.length !== 2) {
continue;
}
if (decodeURIComponent(values[0]) === name) {
return decodeURIComponent(values[1]);
}
}
return null;
}

function setCookie(name, value, expirationTime) {
self.document.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value) +
'; path=/; expires=' + new Date(expirationTime).toUTCString();
}

function fetchJson(url, payload, timeout, callback) {
var oneTimeCallback = oneTime(callback);
self.setTimeout(function () {
oneTimeCallback(new Error('Request timed out'));
}, timeout);

var xhr = createPostXhrRequest(url);
xhr.onreadystatechange = function () {
if (xhr.readyState < /* STATUS_RECEIVED */ 2) {
return;
}
if (xhr.status < 100 || xhr.status > 599) {
xhr.onreadystatechange = null;
oneTimeCallback(new Error('Unknown HTTP status' + xhr.status));
return;
}
if (xhr.readyState === /* COMPLETE */ 4) {
try {
var json = JSON.parse(xhr.responseText);
if (xhr.status >= 400) {
oneTimeCallback(new Error(json.error ?
json.error.message : ('Invalid response: ' + xhr.responseText)));
} else {
oneTimeCallback(null, json);
}
} catch (e) {
oneTimeCallback(new Error('Invalid response: ' + xhr.responseText));
}
}
};
xhr.onerror = function () {
oneTimeCallback(new Error('Network failure'));
};
xhr.onabort = function () {
oneTimeCallback(new Error('Request aborted'));
};
xhr.send(JSON.stringify(payload));
}

function createPostXhrRequest(url) {
var xhr = new self.XMLHttpRequest();
if ('withCredentials' in xhr) {
xhr.open('POST', url, true);
} else if (typeof self.XDomainRequest !== 'undefined') {
// IE-specific object.
xhr = new self.XDomainRequest();
xhr.open('POST', url);
} else {
throw new Error('CORS is not supported');
}
xhr.withCredentials = true;
xhr.setRequestHeader('Content-Type', 'text/plain;charset=utf-8');
xhr.setRequestHeader('Accept', 'application/json');
return xhr;
}

function oneTime(callback) {
var called = false;
return function () {
if (!called) {
callback.apply(null, arguments);
called = true;
}
}
}


self.GoogleAmpCidApi = {
getScopedCid: getScopedCid
};

if (Object.prototype.toString.call(self.googleAmpCidApiOnload) === '[object Array]') {
self.googleAmpCidApiOnload.forEach(function (listener) {
listener(self.GoogleAmpCidApi);
});
}

self.googleAmpCidApiOnload = {
push: function (listener) {
listener(self.GoogleAmpCidApi);
}
};
30 changes: 30 additions & 0 deletions package.json
@@ -0,0 +1,30 @@
{
"name": "ampcidapi",
"version": "1.0.0",
"description": "AMP Client ID Library",
"main": "index.js",
"scripts": {
"test": "echo 'var self = {};' > test.tmp.js; cat index.js test.js >> test.tmp.js; node_modules/mocha/bin/mocha test.tmp.js",
"server": "./node_modules/http-server/bin/http-server"
},
"repository": {
"type": "git",
"url": "git+https://github.com/google/amp-client-id-library.git"
},
"keywords": [
"AMP"
],
"author": "AMP team",
"license": "Apache-2.0",
"bugs": {
"url": "https://github.com/google/amp-client-id-library/issues"
},
"homepage": "https://github.com/google/amp-client-id-library#readme",
"devDependencies": {
"chai": "3.5.0",
"http-server": "0.10.0",
"mocha": "2.5.3",
"sinon": "1.17.7",
"sinon-chai": "2.8.0"
}
}

0 comments on commit 92cb90b

Please sign in to comment.