Skip to content

Commit

Permalink
refactor(ApiCredentials)!: allow none, bearer, classic and custom API…
Browse files Browse the repository at this point in the history
… authentication (#213)
  • Loading branch information
fraxken authored Jan 4, 2025
1 parent dc858fe commit 5e6cb66
Show file tree
Hide file tree
Showing 8 changed files with 152 additions and 117 deletions.
24 changes: 19 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,10 @@ import { GrafanaApi } from "@myunisoft/loki";
import { LogQL, StreamSelector } from "@sigyn/logql";

const api = new GrafanaApi({
// Note: if not provided, it will load process.env.GRAFANA_API_TOKEN
apiToken: "...",
authentication: {
type: "bearer",
token: process.env.GRAFANA_API_TOKEN!
},
remoteApiURL: "https://name.loki.com"
});

Expand Down Expand Up @@ -83,11 +85,11 @@ for (const { verb, endpoint } of logs) {
### GrafanaAPI

```ts
export interface GrafanaApiOptions {
interface GrafanaApiOptions {
/**
* Grafana API Token
* If omitted then no authorization is dispatched to requests
*/
apiToken?: string;
authentication?: ApiCredentialAuthorizationOptions;
/**
* User-agent HTTP header to forward to Grafana/Loki API
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agents
Expand All @@ -98,6 +100,18 @@ export interface GrafanaApiOptions {
*/
remoteApiURL: string | URL;
}

type ApiCredentialAuthorizationOptions = {
type: "bearer";
token: string;
} | {
type: "classic";
username: string;
password: string;
} | {
type: "custom";
authorization: string;
};
```

### Sub-class
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.js",
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts"
"import": "./dist/index.mjs"
}
},
"files": [
Expand Down
45 changes: 37 additions & 8 deletions src/class/ApiCredential.class.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,53 @@

export type ApiCredentialAuthorizationOptions = {
type: "bearer";
token: string;
} | {
type: "classic";
username: string;
password: string;
} | {
type: "custom";
authorization: string;
};

export class ApiCredential {
private token: string;
private authorization: string | null;
private userAgent: string | null;

static buildAuthorizationHeader(
authorizationOptions: ApiCredentialAuthorizationOptions
): string {
switch (authorizationOptions.type) {
case "bearer":
return `Bearer ${authorizationOptions.token}`;
case "classic": {
const { username, password } = authorizationOptions;

return Buffer
.from(`${username}:${password}`)
.toString("base64");
}
case "custom":
default:
return authorizationOptions.authorization;
}
}

constructor(
token?: string,
authorizationOptions?: ApiCredentialAuthorizationOptions,
userAgent?: string
) {
this.token = token ?? process.env.GRAFANA_API_TOKEN!;
this.authorization = authorizationOptions ?
ApiCredential.buildAuthorizationHeader(authorizationOptions) :
null;
this.userAgent = userAgent ?? null;

if (typeof this.token === "undefined") {
throw new Error("API token must be provided to use the Grafana API");
}
}

get httpOptions() {
return {
headers: {
authorization: `Bearer ${this.token}`,
...(this.authorization === null ? {} : { authorization: this.authorization }),
...(this.userAgent === null ? {} : { "User-Agent": this.userAgent })
}
};
Expand Down
16 changes: 10 additions & 6 deletions src/class/GrafanaApi.class.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// Import Internal Dependencies
import { ApiCredential } from "./ApiCredential.class.js";
import {
ApiCredential,
type ApiCredentialAuthorizationOptions
} from "./ApiCredential.class.js";
import {
Datasources,
Datasource
Expand All @@ -13,9 +16,9 @@ import {

export interface GrafanaApiOptions {
/**
* Grafana API Token
* If omitted then no authorization is dispatched to requests
*/
apiToken?: string;
authentication?: ApiCredentialAuthorizationOptions;
/**
* User-agent HTTP header to forward to Grafana/Loki API
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agents
Expand All @@ -35,9 +38,9 @@ export class GrafanaApi {
public Loki: Loki;

constructor(options: GrafanaApiOptions) {
const { apiToken, userAgent, remoteApiURL } = options;
const { authentication, userAgent, remoteApiURL } = options;

this.credential = new ApiCredential(apiToken, userAgent);
this.credential = new ApiCredential(authentication, userAgent);
this.remoteApiURL = typeof remoteApiURL === "string" ?
new URL(remoteApiURL) :
remoteApiURL;
Expand All @@ -58,5 +61,6 @@ export type {
Datasource,
LokiLabelValuesOptions,
LokiLabelsOptions,
LokiQueryOptions
LokiQueryOptions,
ApiCredentialAuthorizationOptions
};
79 changes: 57 additions & 22 deletions test/ApiCredential.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,47 +7,82 @@ import crypto from "node:crypto";
import { ApiCredential } from "../src/class/ApiCredential.class.js";

describe("ApiCredential", () => {
describe("constructor", () => {
beforeEach(() => {
delete process.env.GRAFANA_API_TOKEN;
describe("httpOptions getter", () => {
it("should return the bearer token provided in the constructor", () => {
const bearerAuth = {
type: "bearer" as const,
token: crypto.randomBytes(4).toString("hex")
};

const sdk = new ApiCredential(bearerAuth);
assert.deepEqual(
sdk.httpOptions,
{
headers: {
authorization: ApiCredential.buildAuthorizationHeader(bearerAuth)
}
}
);
});

it("should throw an Error if no api token is provided", () => {
const expectedError = {
name: "Error",
message: "API token must be provided to use the Grafana API"
it("should return classic authentication (username and password) provided in the constructor as base64", () => {
const classicAuth = {
type: "classic" as const,
username: "foo",
password: "bar"
};

assert.throws(() => {
new ApiCredential();
}, expectedError);
const sdk = new ApiCredential(classicAuth);
assert.deepEqual(
sdk.httpOptions,
{
headers: {
authorization: ApiCredential.buildAuthorizationHeader(classicAuth)
}
}
);
});
});

describe("httpOptions getter", () => {
it("should return apiToken provided in the constructor", () => {
const apiToken = crypto.randomBytes(4).toString("hex");
it("should return custom authentication provided in the constructor", () => {
const authorization = "hello world";
const classicAuth = {
type: "custom" as const,
authorization
};

const sdk = new ApiCredential(apiToken);
assert.deepEqual(sdk.httpOptions, {
headers: {
authorization: `Bearer ${apiToken}`
const sdk = new ApiCredential(classicAuth);
assert.deepEqual(
sdk.httpOptions,
{
headers: {
authorization
}
}
});
);
});

it("should inject User-Agent header", () => {
const apiToken = crypto.randomBytes(4).toString("hex");
const token = crypto.randomBytes(4).toString("hex");
const userAgent = "my-super-agent";

const sdk = new ApiCredential(apiToken, userAgent);
const sdk = new ApiCredential({
type: "bearer",
token
}, userAgent);

assert.deepEqual(sdk.httpOptions, {
headers: {
authorization: `Bearer ${apiToken}`,
authorization: `Bearer ${token}`,
"User-Agent": userAgent
}
});
});

it("should return empty headers if no authentication methods is provided", () => {
const sdk = new ApiCredential();
assert.deepEqual(sdk.httpOptions, {
headers: {}
});
});
});
});
2 changes: 0 additions & 2 deletions test/Datasources.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,10 @@ describe("GrafanaApi.DataSources", () => {
const agentPoolInterceptor = kMockAgent.get(kDummyURL);

before(() => {
process.env.GRAFANA_API_TOKEN = "";
setGlobalDispatcher(kMockAgent);
});

after(() => {
delete process.env.GRAFANA_API_TOKEN;
setGlobalDispatcher(kDefaultDispatcher);
});

Expand Down
50 changes: 27 additions & 23 deletions test/GrafanaApi.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,42 +13,46 @@ const remoteApiURL = "https://nodejs.org";

describe("GrafanaApi", () => {
describe("constructor", () => {
beforeEach(() => {
delete process.env.GRAFANA_API_TOKEN;
});

it("should instanciate sub-API", () => {
const api = new GrafanaApi({
remoteApiURL,
apiToken: "foobar"
remoteApiURL
});

assert.ok(api.Datasources instanceof Datasources);
assert.ok(api.Loki instanceof Loki);
});

it("should throw an Error if no api token is provided", () => {
const expectedError = {
name: "Error",
message: "API token must be provided to use the Grafana API"
};
it("should load constructor with authentication and bearer token", () => {
const token = crypto.randomBytes(4).toString("hex");

assert.throws(() => {
new GrafanaApi({ remoteApiURL });
}, expectedError);
assert.doesNotThrow(() => new GrafanaApi({
remoteApiURL,
authentication: {
type: "bearer",
token
}
}));
});

it("should load token from ENV if no token argument is provided", () => {
const apiToken = crypto.randomBytes(4).toString("hex");
process.env.GRAFANA_API_TOKEN = apiToken;

assert.doesNotThrow(() => new GrafanaApi({ remoteApiURL }));
it("should load constructor with a classic authentication", () => {
assert.doesNotThrow(() => new GrafanaApi({
remoteApiURL,
authentication: {
type: "classic",
username: "foo",
password: "bar"
}
}));
});

it("should load token from apiToken constructor option argument", () => {
const apiToken = crypto.randomBytes(4).toString("hex");

assert.doesNotThrow(() => new GrafanaApi({ remoteApiURL, apiToken }));
it("should load constructor with a custom authentication", () => {
assert.doesNotThrow(() => new GrafanaApi({
remoteApiURL,
authentication: {
type: "custom",
authorization: "hello world"
}
}));
});
});
});
Expand Down
Loading

0 comments on commit 5e6cb66

Please sign in to comment.