Skip to content

Commit 0b06297

Browse files
authored
API: use <meta> to define supported API version and trigger CustomEvent (#64)
Let users to define `<meta name="readthedocs-api-version" content="1.0">` to tell Read the Docs client what is the API scheme version supported by them. When our client request the API data, if the `api_version` returned does not match with the one expected by the user, another request is done to force a particular API scheme version. Then, we dispatch a `readthedocsdataready` custom event ~~and expose `window.readthedocs`~~, to let our users know this data is ready to be consumed by their own integrations. Closes #60 Closes #61 Closes #17 Closes readthedocs/readthedocs.org#9957 Closes #250
1 parent acdc373 commit 0b06297

8 files changed

+186
-25
lines changed

dist/readthedocs-addons.js

+10-10
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/readthedocs-addons.js.map

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/index.html

+11
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,19 @@
11
<html>
22
<head>
3+
<meta name="readthedocs-addons-api-version" content="1" />
34
<title>Documentation Addons - Read the Docs</title>
45
<meta name="readthedocs-project-slug" content="test-builds" />
56
<meta name="readthedocs-version-slug" content="latest" />
7+
<script>
8+
// Example of using "readthedocs-addons-data-ready" with a different API version supported
9+
document.addEventListener(
10+
"readthedocs-addons-data-ready",
11+
function (event) {
12+
const data = event.detail.data();
13+
console.debug(`Project slug using CustomEvent: '${data.projects.current.slug}'`);
14+
}
15+
);
16+
</script>
617
<meta name="readthedocs-resolver-filename" content="/index.html" />
718
</head>
819
<body>

src/events.js

+46
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,53 @@
1+
import { getMetadataAddonsAPIVersion } from "./readthedocs-config";
2+
import { ADDONS_API_VERSION } from "./utils";
3+
14
export const EVENT_READTHEDOCS_SEARCH_SHOW = "readthedocs-search-show";
25
export const EVENT_READTHEDOCS_SEARCH_HIDE = "readthedocs-search-hide";
36
export const EVENT_READTHEDOCS_DOCDIFF_ADDED_REMOVED_SHOW =
47
"readthedocs-docdiff-added-removed-show";
58
export const EVENT_READTHEDOCS_DOCDIFF_HIDE = "readthedocs-docdiff-hide";
69
export const EVENT_READTHEDOCS_FLYOUT_SHOW = "readthedocs-flyout-show";
710
export const EVENT_READTHEDOCS_FLYOUT_HIDE = "readthedocs-flyout-hide";
11+
export const EVENT_READTHEDOCS_ADDONS_DATA_READY =
12+
"readthedocs-addons-data-ready";
13+
14+
/**
15+
* Object to pass to user subscribing to `EVENT_READTHEDOCS_ADDONS_DATA_READY`.
16+
*
17+
* This object allows us to have a better communication with the user.
18+
* Instead of passing the raw data, we pass this object and enforce them
19+
* to use it in an expected way:
20+
*
21+
* document.addEventListener(
22+
* "readthedocs-addons-data-ready",
23+
* function (event) {
24+
* const data = event.detail.data();
25+
* }
26+
* );
27+
*
28+
* Note that we perform some checks/validations when `.data()` is called,
29+
* to make sure the user is using the pattern in the expected way.
30+
* Otherwise, we throw an exception.
31+
*/
32+
export class ReadTheDocsEventData {
33+
constructor(data) {
34+
this._initialized = false;
35+
this._data = data;
36+
}
37+
38+
initialize() {
39+
const metadataAddonsAPIVersion = getMetadataAddonsAPIVersion();
40+
if (metadataAddonsAPIVersion === undefined) {
41+
throw `Subscribing to '${EVENT_READTHEDOCS_ADDONS_DATA_READY}' requires defining the '<meta name="readthedocs-addons-api-version" content="${ADDONS_API_VERSION}" />' tag in the HTML.`;
42+
}
43+
44+
this._initialized = true;
45+
}
46+
47+
data() {
48+
if (!this._initialized) {
49+
this.initialize();
50+
}
51+
return this._data;
52+
}
53+
}

src/index.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,15 @@ export function setup() {
5757
if (addon.isEnabled(config)) {
5858
promises.push(
5959
new Promise((resolve) => {
60-
resolve(new addon(config));
60+
return resolve(new addon(config));
6161
}),
6262
);
6363
}
6464
}
6565
return Promise.all(promises);
6666
})
6767
.then(() => {
68-
resolve();
68+
return resolve();
6969
})
7070
.catch((err) => {
7171
console.error(err);

src/readthedocs-config.js

+108-11
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,36 @@
11
import { default as fetch } from "unfetch";
2+
import {
3+
EVENT_READTHEDOCS_ADDONS_DATA_READY,
4+
ReadTheDocsEventData,
5+
} from "./events";
26
import {
37
CLIENT_VERSION,
8+
IS_TESTING,
49
ADDONS_API_VERSION,
510
ADDONS_API_ENDPOINT,
6-
IS_TESTING,
711
} from "./utils";
812

913
/**
10-
* Load Read the Docs configuration from API endpoint.
14+
* Get the Read the Docs API version supported by user's integrations.
1115
*
1216
*/
13-
export function getReadTheDocsConfig(sendUrlParam) {
17+
export function getMetadataAddonsAPIVersion() {
18+
const meta = document.querySelector(
19+
"meta[name=readthedocs-addons-api-version]",
20+
);
21+
if (meta !== null) {
22+
return meta.getAttribute("content");
23+
}
24+
return undefined;
25+
}
26+
27+
/**
28+
* Get the Addons API endpoint URL to hit.
29+
*
30+
* It uses META HTML tags to get project/version slugs and `sendUrlParam` to
31+
* decide whether or not sending `url=`.
32+
*/
33+
function _getApiUrl(sendUrlParam, apiVersion) {
1434
const metaProject = document.querySelector(
1535
"meta[name='readthedocs-project-slug']",
1636
);
@@ -22,7 +42,7 @@ export function getReadTheDocsConfig(sendUrlParam) {
2242
let versionSlug;
2343
let params = {
2444
"client-version": CLIENT_VERSION,
25-
"api-version": ADDONS_API_VERSION,
45+
"api-version": apiVersion,
2646
};
2747

2848
if (sendUrlParam) {
@@ -44,13 +64,90 @@ export function getReadTheDocsConfig(sendUrlParam) {
4464
url = "/_/readthedocs-addons.json";
4565
}
4666

47-
return fetch(url, {
48-
method: "GET",
49-
}).then((response) => {
50-
if (!response.ok) {
51-
console.debug("Error parsing configuration data");
52-
return undefined;
67+
return url;
68+
}
69+
70+
function getReadTheDocsUserConfig(sendUrlParam) {
71+
// Create a Promise here to handle the user request in a different async task.
72+
// This allows us to start executing our integration independently from the user one.
73+
return new Promise((resolve, reject) => {
74+
// Note we force the user to define the `<meta>` tag to be able to use Read the Docs data directly.
75+
// This is to keep forward/backward compatibility without breaking integrations.
76+
const metadataAddonsAPIVersion = getMetadataAddonsAPIVersion();
77+
78+
if (
79+
metadataAddonsAPIVersion !== undefined &&
80+
metadataAddonsAPIVersion !== ADDONS_API_VERSION
81+
) {
82+
// When the addons API version doesn't match the one defined via `<meta>` tag by the user,
83+
// we perform another request to get the Read the Docs response in the structure
84+
// that's supported by the user and dispatch a custom event letting them know
85+
// this data is ready to be consumed under `event.detail.data()`.
86+
const userApiUrl = _getApiUrl(sendUrlParam, metadataAddonsAPIVersion);
87+
88+
// TODO: revert this change and use the correct URL here
89+
const url = "/_/readthedocs-addons.json";
90+
fetch(url, {
91+
method: "GET",
92+
}).then((response) => {
93+
if (!response.ok) {
94+
return reject(
95+
"Error hitting addons API endpoint for user api-version",
96+
);
97+
}
98+
// Return the data in the API version requested.
99+
return resolve(response.json());
100+
});
53101
}
54-
return response.json();
102+
103+
// If the API versions match, we return `undefined`.
104+
return resolve(undefined);
105+
}).catch((error) => {
106+
console.error(error);
55107
});
56108
}
109+
110+
/**
111+
* Load Read the Docs configuration from API endpoint.
112+
*
113+
*/
114+
export function getReadTheDocsConfig(sendUrlParam) {
115+
return new Promise((resolve, reject) => {
116+
let dataUser;
117+
const defaultApiUrl = _getApiUrl(sendUrlParam, ADDONS_API_VERSION);
118+
119+
fetch(defaultApiUrl, {
120+
method: "GET",
121+
})
122+
.then((response) => {
123+
if (!response.ok) {
124+
return reject("Error hitting addons API endpoint");
125+
}
126+
return response.json();
127+
})
128+
.then((data) => {
129+
// Trigger a new task here to hit the API again in case the version
130+
// request missmatchs the one the user expects.
131+
getReadTheDocsUserConfig(sendUrlParam).then((dataUser) => {
132+
// Expose `dataUser` if available or the `data` already requested.
133+
const dataEvent = dataUser !== undefined ? dataUser : data;
134+
135+
// Trigger the addons data ready CustomEvent to with the data the user is expecting.
136+
return dispatchEvent(
137+
EVENT_READTHEDOCS_ADDONS_DATA_READY,
138+
document,
139+
new ReadTheDocsEventData(dataEvent),
140+
);
141+
});
142+
143+
return resolve(data);
144+
});
145+
}).catch((error) => {
146+
console.error(error);
147+
});
148+
}
149+
150+
function dispatchEvent(eventName, element, data) {
151+
const event = new CustomEvent(eventName, { detail: data });
152+
element.dispatchEvent(event);
153+
}

tests/index.test.html

+1-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
describe("Main library", () => {
4444
it("hits Read the Docs addons API", async () => {
4545
const matchUrl = new RegExp(`^${ADDONS_API_ENDPOINT}`, "g");
46-
server.respondWith("GET", matchUrl, [200, {}, "{}"]);
46+
server.respondWith("GET", matchUrl, [200, {}, '{"testing": true}']);
4747

4848
// Our .setup() function returns a Promise here and we want to wait for it.
4949
await readthedocs.setup();

webpack.config.js

+7
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ module.exports = (env, argv) => {
8686
ignored: ["/node_modules/", "**/node_modules/"],
8787
},
8888
devServer: {
89+
// Allow CORS when working locally
90+
headers: {
91+
"Access-Control-Allow-Origin": "*",
92+
"Access-Control-Allow-Methods":
93+
"GET, POST, PUT, DELETE, PATCH, OPTIONS",
94+
"Access-Control-Allow-Headers": "*",
95+
},
8996
open: false,
9097
port: 8000,
9198
hot: false,

0 commit comments

Comments
 (0)