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

Enable external data sources to be added and accessed #188

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
11 changes: 10 additions & 1 deletion .all-contributorsrc
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,17 @@
"bug",
"code"
]
},
{
"login": "sfishel18",
"name": "Simon Fishel",
"avatar_url": "https://avatars.githubusercontent.com/u/294695?v=4",
"profile": "https://github.com/sfishel18",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
"linkToUsage": false
}
}
3 changes: 3 additions & 0 deletions .esbuild.browser.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
const esbuildCommon = require("./.esbuild.common");

require('esbuild').buildSync({
...esbuildCommon,
entryPoints: ['src/index.js'],
outdir: 'dist/iife',
globalName: 'co2',
Expand Down
7 changes: 7 additions & 0 deletions .esbuild.common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const CO2JS_VERSION = require("./package.json").version;

module.exports = {
define: {
"process.env.CO2JS_VERSION": JSON.stringify(CO2JS_VERSION),
},
};
4 changes: 3 additions & 1 deletion .esbuild.esm.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ const esbuild = require('esbuild')
// For this build however we need to filter out some extra files
// that are used for nodejs, but not in browsers, so we use the
// library directly instead of using `esbuild-plugin-glob` as a plugin
const glob = require('tiny-glob');
const glob = require('tiny-glob')
const esbuildCommon = require('./.esbuild.common')

async function main() {
const results = await glob('src/**/!(*.test.js|test-constants.js|!(*.js))')
Expand All @@ -12,6 +13,7 @@ async function main() {
const justBrowserCompatibleFiles = results.filter(filepath => !filepath.endsWith('node.js'))

esbuild.build({
...esbuildCommon,
entryPoints: justBrowserCompatibleFiles,
bundle: false,
minify: false,
Expand Down
2 changes: 2 additions & 0 deletions .esbuild.node.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
const { globPlugin } = require('esbuild-plugin-glob');
const esbuildCommon = require('./.esbuild.common');

function main() {
require('esbuild').build({
...esbuildCommon,
entryPoints: ['src/**/!(*.test.js|test-constants.js|!(*.js))'],
bundle: false,
minify: false,
Expand Down
28 changes: 28 additions & 0 deletions __mocks__/https.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { getApiRequestHeaders } from "../src/helpers/index.js";
const https = jest.createMockFromModule("https");
import { Stream } from "stream";

const stream = new Stream();

https.get.mockImplementation((url, options, callback) => {
url, { headers: getApiRequestHeaders("TestRunner") }, callback(stream);
if (url.includes("greencheckmulti")) {
stream.emit(
"data",
Buffer.from(
`{"google.com": {"url":"google.com","hosted_by":"Google Inc.","hosted_by_website":"https://www.google.com","partner":null,"green":true}}`
)
);
} else {
stream.emit(
"data",
Buffer.from(
`{"url":"google.com","hosted_by":"Google Inc.","hosted_by_website":"https://www.google.com","partner":null,"green":true}`
)
);
}

stream.emit("end");
});

module.exports = https;
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
"intensity-data:average": "node data/functions/generate_average_co2.js",
"intensity-data:marginal": "node data/functions/generate_marginal_co2.js",
"intensity-data": "npm run intensity-data:average && npm run intensity-data:marginal && npm run format-data",
"format-data": "cd data && prettier --write '**/*.{js,json}'"
"format-data": "cd data && prettier --write '**/*.{js,json}'",
"version": "npm run build"
},
"keywords": [
"sustainability",
Expand Down Expand Up @@ -75,4 +76,4 @@
"type": "git",
"url": "https://github.com/thegreenwebfoundation/co2.js.git"
}
}
}
28 changes: 28 additions & 0 deletions src/data.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"use strict";
import ElectricityMapsApi from "./data/external/electricityMapsApi.js";

class DataSources {
constructor() {
/**
* @type {String} - The source of the data.
*/
this.source = undefined;
}

/**
* Set the source of the data.
* @param {string} source - The source of the data.
* @throws {Error} Will throw an error if the source is unknown or not provided.
*/
set(source) {
switch (source) {
case "electricityMapsApi":
this.source = new ElectricityMapsApi();
break;
default:
throw new Error(`Unknown data source: ${source}`);
}
}
}
export { DataSources };
export default DataSources;
22 changes: 22 additions & 0 deletions src/data.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"use strict";

import DataSources from "./data.js";

describe("DataSources", () => {
let dataSources;
describe("sets the source", () => {
beforeEach(() => {
dataSources = new DataSources();
});
it("throws an error when the data source is not defined", () => {
expect(() => dataSources.set()).toThrow(
new Error("Unknown data source: undefined")
);
});
it("sets the source correctly", () => {
expect(() => dataSources.set("electricityMapsApi")).not.toThrow(
new Error("Unknown data source: unknown")
);
});
});
});
163 changes: 163 additions & 0 deletions src/data/external/electricityMapsApi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* Type definition for the options of the ElectricityMapsApi.
* @typedef {Object} ElectricityMapsApiOptions
* @property {string} authToken - The authentication token for the API.
*/

/**
* @typedef {Object} LatestData
* @property {string} zone - The zone identifier.
* @property {number} carbonIntensity - The carbon intensity value.
* @property {string} datetime - The date and time of the data.
* @property {string} updatedAt - The date and time the data was last updated.
* @property {string} createdAt - The date and time the data was created.
* @property {string} emissionFactorType - The type of emission factor used.
* @property {boolean} isEstimated - Whether the data is estimated.
* @property {string} estimationMethod - The method used to estimate the data.
*/

/**
* @typedef {Object} ZoneData
* @property {string} countryName - The name of the country the zone belongs to.
* @property {string} zoneName - The zone identifier.
* @property {string[]} access - an array of strings listing the API endpoints the zone can be accessed from
*/

/**
* @typedef {Object} HistoryData
* @property {Object[]} history - An array of historical data.
* @property {string} history.zone - The zone identifier.
* @property {number} history.carbonIntensity - The carbon intensity value.
* @property {string} history.datetime - The date and time of the data.
* @property {string} history.updatedAt - The date and time the data was last updated.
* @property {string} history.createdAt - The date and time the data was created.
* @property {string} history.emissionFactorType - The type of emission factor used.
* @property {boolean} history.isEstimated - Whether the data is estimated.
* @property {string} history.estimationMethod - The method used to estimate the data.
*/

class ElectricityMapsApi {
/**
* Create an instance of ElectricityMapsApi.
* @param {ElectricityMapsApiOptions} options - The options for the ElectricityMapsApi.
*/
constructor(options) {
/**
* @type {string} The base URL of the API.
*/
this.baseUrl = "https://api-access.electricitymaps.com/free-tier";

/**
* @type {string} The authentication token for the API.
*/
this.authToken = options?.authToken || undefined;

/**
* @type {string} The name of the API.
*/
this.name = "Electricity Maps API - Free Tier";

/**
* @type {string} The documentation URL of the API.
*/
this.docs = "https://static.electricitymaps.com/api/docs/index.html";
}

/**
* Fetches the latest grid intensity data from the API.
* @param {string} zone - The zone identifier.
* @param {string} lat - The latitude of the location.
* @param {string} lon - The longitude of the location.
* @returns {Promise<LatestData>} A promise that resolves with the latest grid intensity data.
* @throws {Error} Will throw an error if the authentication token is not provided.
* @throws {Error} Will throw an error if the zone or lat & lon are not provided.
*/
async getLatest(zone, lat, lon) {
if (!this.authToken || this.authToken === undefined) {
throw new Error(
"An authentication token is required to access this endpoint."
);
}

if (!zone && (!lat || !lon)) {
throw new Error(
"Either a zone or a latitude and longitude value is required."
);
}

const query = `${lat ? `lat=${lat}&` : ""}${lon ? `lon=${lon}&` : ""}${
zone ? `zone=${zone}` : ""
}`;
Comment on lines +88 to +90

Choose a reason for hiding this comment

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

Not sure how big of a deal this is, but with this current usage it's possible to call the API with both a zone and coordinates. In this case the coordinates overrule on our end :)

const url = `${this.baseUrl}/carbon-intensity/latest?${query}`;
const response = await fetch(url, {
method: "GET",
headers: {
"auth-token": this.authToken,
},
});
const data = await response.json();

if (data.status === "error") {
throw new Error(data.message);
}

return { data };
}

/**
* Fetches the historical grid intensity data from the API.
* @param {string} zone - The zone identifier.
* @param {string} lat - The latitude of the location.
* @param {string} lon - The longitude of the location.
* @returns {Promise<HistoryData>} A promise that resolves with the historical grid intensity data.
* @throws {Error} Will throw an error if the authentication token is not provided.
* @throws {Error} Will throw an error if the zone or lat & lon are not provided.
*/

async getHistory(zone, lat, lon) {
if (!this.authToken || this.authToken === undefined) {
throw new Error(
"An authentication token is required to access this endpoint."
);
}

if (!zone && (!lat || !lon)) {
throw new Error(
"Either a zone or a latitude and longitude value is required."
);
}

const query = `${lat ? `lat=${lat}&` : ""}${lon ? `lon=${lon}&` : ""}${
zone ? `zone=${zone}` : ""
}`;

const url = `${this.baseUrl}/carbon-intensity/history?${query}`;
const response = await fetch(url, {
method: "GET",
headers: {
"auth-token": this.authToken,
},
});
const data = await response.json();

if (data.status === "error") {
throw new Error(data.message);
}

return data.history;
}

/**
* Fetches the zone data from the API.
* @returns {Promise<ZoneData[]>} A promise that resolves with the data for all zones.
*/
async getZones() {
const url = `${this.baseUrl}/zones`;
const response = await fetch(url);
const data = await response.json();
return data;
}
}

export { ElectricityMapsApi };
export default ElectricityMapsApi;
Loading
Loading