Skip to content

Commit

Permalink
API: use <meta> to define supported API version and trigger `Custom…
Browse files Browse the repository at this point in the history
…Event` (#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
  • Loading branch information
humitos authored Apr 15, 2024
1 parent acdc373 commit 0b06297
Show file tree
Hide file tree
Showing 8 changed files with 186 additions and 25 deletions.
20 changes: 10 additions & 10 deletions dist/readthedocs-addons.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/readthedocs-addons.js.map

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
<html>
<head>
<meta name="readthedocs-addons-api-version" content="1" />
<title>Documentation Addons - Read the Docs</title>
<meta name="readthedocs-project-slug" content="test-builds" />
<meta name="readthedocs-version-slug" content="latest" />
<script>
// Example of using "readthedocs-addons-data-ready" with a different API version supported
document.addEventListener(
"readthedocs-addons-data-ready",
function (event) {
const data = event.detail.data();
console.debug(`Project slug using CustomEvent: '${data.projects.current.slug}'`);
}
);
</script>
<meta name="readthedocs-resolver-filename" content="/index.html" />
</head>
<body>
Expand Down
46 changes: 46 additions & 0 deletions src/events.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,53 @@
import { getMetadataAddonsAPIVersion } from "./readthedocs-config";
import { ADDONS_API_VERSION } from "./utils";

export const EVENT_READTHEDOCS_SEARCH_SHOW = "readthedocs-search-show";
export const EVENT_READTHEDOCS_SEARCH_HIDE = "readthedocs-search-hide";
export const EVENT_READTHEDOCS_DOCDIFF_ADDED_REMOVED_SHOW =
"readthedocs-docdiff-added-removed-show";
export const EVENT_READTHEDOCS_DOCDIFF_HIDE = "readthedocs-docdiff-hide";
export const EVENT_READTHEDOCS_FLYOUT_SHOW = "readthedocs-flyout-show";
export const EVENT_READTHEDOCS_FLYOUT_HIDE = "readthedocs-flyout-hide";
export const EVENT_READTHEDOCS_ADDONS_DATA_READY =
"readthedocs-addons-data-ready";

/**
* Object to pass to user subscribing to `EVENT_READTHEDOCS_ADDONS_DATA_READY`.
*
* This object allows us to have a better communication with the user.
* Instead of passing the raw data, we pass this object and enforce them
* to use it in an expected way:
*
* document.addEventListener(
* "readthedocs-addons-data-ready",
* function (event) {
* const data = event.detail.data();
* }
* );
*
* Note that we perform some checks/validations when `.data()` is called,
* to make sure the user is using the pattern in the expected way.
* Otherwise, we throw an exception.
*/
export class ReadTheDocsEventData {
constructor(data) {
this._initialized = false;
this._data = data;
}

initialize() {
const metadataAddonsAPIVersion = getMetadataAddonsAPIVersion();
if (metadataAddonsAPIVersion === undefined) {
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.`;
}

this._initialized = true;
}

data() {
if (!this._initialized) {
this.initialize();
}
return this._data;
}
}
4 changes: 2 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,15 @@ export function setup() {
if (addon.isEnabled(config)) {
promises.push(
new Promise((resolve) => {
resolve(new addon(config));
return resolve(new addon(config));
}),
);
}
}
return Promise.all(promises);
})
.then(() => {
resolve();
return resolve();
})
.catch((err) => {
console.error(err);
Expand Down
119 changes: 108 additions & 11 deletions src/readthedocs-config.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,36 @@
import { default as fetch } from "unfetch";
import {
EVENT_READTHEDOCS_ADDONS_DATA_READY,
ReadTheDocsEventData,
} from "./events";
import {
CLIENT_VERSION,
IS_TESTING,
ADDONS_API_VERSION,
ADDONS_API_ENDPOINT,
IS_TESTING,
} from "./utils";

/**
* Load Read the Docs configuration from API endpoint.
* Get the Read the Docs API version supported by user's integrations.
*
*/
export function getReadTheDocsConfig(sendUrlParam) {
export function getMetadataAddonsAPIVersion() {
const meta = document.querySelector(
"meta[name=readthedocs-addons-api-version]",
);
if (meta !== null) {
return meta.getAttribute("content");
}
return undefined;
}

/**
* Get the Addons API endpoint URL to hit.
*
* It uses META HTML tags to get project/version slugs and `sendUrlParam` to
* decide whether or not sending `url=`.
*/
function _getApiUrl(sendUrlParam, apiVersion) {
const metaProject = document.querySelector(
"meta[name='readthedocs-project-slug']",
);
Expand All @@ -22,7 +42,7 @@ export function getReadTheDocsConfig(sendUrlParam) {
let versionSlug;
let params = {
"client-version": CLIENT_VERSION,
"api-version": ADDONS_API_VERSION,
"api-version": apiVersion,
};

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

return fetch(url, {
method: "GET",
}).then((response) => {
if (!response.ok) {
console.debug("Error parsing configuration data");
return undefined;
return url;
}

function getReadTheDocsUserConfig(sendUrlParam) {
// Create a Promise here to handle the user request in a different async task.
// This allows us to start executing our integration independently from the user one.
return new Promise((resolve, reject) => {
// Note we force the user to define the `<meta>` tag to be able to use Read the Docs data directly.
// This is to keep forward/backward compatibility without breaking integrations.
const metadataAddonsAPIVersion = getMetadataAddonsAPIVersion();

if (
metadataAddonsAPIVersion !== undefined &&
metadataAddonsAPIVersion !== ADDONS_API_VERSION
) {
// When the addons API version doesn't match the one defined via `<meta>` tag by the user,
// we perform another request to get the Read the Docs response in the structure
// that's supported by the user and dispatch a custom event letting them know
// this data is ready to be consumed under `event.detail.data()`.
const userApiUrl = _getApiUrl(sendUrlParam, metadataAddonsAPIVersion);

// TODO: revert this change and use the correct URL here
const url = "/_/readthedocs-addons.json";
fetch(url, {
method: "GET",
}).then((response) => {
if (!response.ok) {
return reject(
"Error hitting addons API endpoint for user api-version",
);
}
// Return the data in the API version requested.
return resolve(response.json());
});
}
return response.json();

// If the API versions match, we return `undefined`.
return resolve(undefined);
}).catch((error) => {
console.error(error);
});
}

/**
* Load Read the Docs configuration from API endpoint.
*
*/
export function getReadTheDocsConfig(sendUrlParam) {
return new Promise((resolve, reject) => {
let dataUser;
const defaultApiUrl = _getApiUrl(sendUrlParam, ADDONS_API_VERSION);

fetch(defaultApiUrl, {
method: "GET",
})
.then((response) => {
if (!response.ok) {
return reject("Error hitting addons API endpoint");
}
return response.json();
})
.then((data) => {
// Trigger a new task here to hit the API again in case the version
// request missmatchs the one the user expects.
getReadTheDocsUserConfig(sendUrlParam).then((dataUser) => {
// Expose `dataUser` if available or the `data` already requested.
const dataEvent = dataUser !== undefined ? dataUser : data;

// Trigger the addons data ready CustomEvent to with the data the user is expecting.
return dispatchEvent(
EVENT_READTHEDOCS_ADDONS_DATA_READY,
document,
new ReadTheDocsEventData(dataEvent),
);
});

return resolve(data);
});
}).catch((error) => {
console.error(error);
});
}

function dispatchEvent(eventName, element, data) {
const event = new CustomEvent(eventName, { detail: data });
element.dispatchEvent(event);
}
2 changes: 1 addition & 1 deletion tests/index.test.html
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
describe("Main library", () => {
it("hits Read the Docs addons API", async () => {
const matchUrl = new RegExp(`^${ADDONS_API_ENDPOINT}`, "g");
server.respondWith("GET", matchUrl, [200, {}, "{}"]);
server.respondWith("GET", matchUrl, [200, {}, '{"testing": true}']);

// Our .setup() function returns a Promise here and we want to wait for it.
await readthedocs.setup();
Expand Down
7 changes: 7 additions & 0 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ module.exports = (env, argv) => {
ignored: ["/node_modules/", "**/node_modules/"],
},
devServer: {
// Allow CORS when working locally
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods":
"GET, POST, PUT, DELETE, PATCH, OPTIONS",
"Access-Control-Allow-Headers": "*",
},
open: false,
port: 8000,
hot: false,
Expand Down

0 comments on commit 0b06297

Please sign in to comment.