Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
  • Loading branch information
jgreywolf committed Nov 28, 2023
2 parents 7b111c9 + 50702cd commit b13b1fe
Show file tree
Hide file tree
Showing 13 changed files with 333 additions and 9 deletions.
41 changes: 41 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
name: Build
on:
pull_request:
push:

jobs:
test:
timeout-minutes: 15
strategy:
matrix:
node: ['18.18.x']
pkg: ['sdk']
runs-on:
labels: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- uses: pnpm/action-setup@v2
with:
version: 8

- name: Setup Node.js ${{ matrix.node }}
uses: actions/setup-node@v4
with:
cache: pnpm
node-version: ${{ matrix.node }}

- name: Install dependencies for ${{ matrix.pkg }}
run: |
pnpm install --frozen-lockfile --filter='...${{ matrix.pkg }}'
- name: Test ${{ matrix.pkg }}
run: |
pnpm --filter='${{ matrix.pkg }}' test
- name: E2E tests (if present) for ${{ matrix.pkg }}
env:
TEST_MERMAIDCHART_API_TOKEN: ${{ secrets.TEST_MERMAIDCHART_API_TOKEN }}
run: |
pnpm --if-present --filter='${{ matrix.pkg }}' test:e2e
41 changes: 39 additions & 2 deletions openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ components:
projectID:
type: string
major:
type: string
type: integer
minor:
type: string
type: integer
title:
type: string
required:
Expand Down Expand Up @@ -160,6 +160,43 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/Document'

/rest-api/documents/{documentID}:
get:
tags:
- document
summary: Gets the given diagram
operationId: getDocument
parameters:
- name: documentID
in: path
required: true
description: The ID of the document to get diagram data for
schema:
type: string
- name: version
in: query
required: false
description: |
The version of the document to get diagram data for.
If not set, defaults to the highest version.
schema:
type: string
example: v0.1
security:
- mermaidchart_auth:
- read
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/Document'
'404':
description: File Not Found

/raw/{documentID}:
get:
tags:
Expand Down
12 changes: 11 additions & 1 deletion packages/sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added

- Compile an ESM version of this codebase for Node.JS v18.
- Add `MermaidChart#getDiagram(diagramID)` function to get a diagram.

## [0.1.1] - 2023-09-08
### Fixed

- Fix `MCDocument` `major`/`minor` type to `number`.

### Fixed

- `MermaidChart#getAuthorizationData()` now correctly sets `state` in the URL
by default.
- `MermaidChart#handleAuthorizationResponse()` now supports relative URLs.

## [0.1.1] - 2023-09-08
- Browser-only build.
5 changes: 4 additions & 1 deletion packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
"build:code:browser": "esbuild src/index.ts --bundle --minify --outfile=dist/bundle.iife.js",
"build:code:node": "esbuild src/index.ts --bundle --platform=node --target=node18.18 --format=esm --packages=external --minify --outfile=dist/index.mjs",
"build:types": "tsc -p ./tsconfig.json --emitDeclarationOnly",
"prepare": "pnpm run build"
"prepare": "pnpm run build",
"test": "vitest src/",
"test:e2e": "vitest --config vitest.config.e2e.ts"
},
"type": "module",
"keywords": [],
Expand All @@ -28,6 +30,7 @@
"uuid": "^9.0.0"
},
"devDependencies": {
"@types/node": "^18.18.0",
"@types/uuid": "^9.0.2"
},
"publishConfig": {
Expand Down
75 changes: 75 additions & 0 deletions packages/sdk/src/index.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* E2E tests
*/
import { MermaidChart } from './index.js';
import { beforeAll, describe, expect, it } from 'vitest';

import process from 'node:process';
import { AxiosError } from 'axios';

let client: MermaidChart;

beforeAll(async() => {
if (!process.env.TEST_MERMAIDCHART_API_TOKEN) {
throw new Error(
"Missing required environment variable TEST_MERMAIDCHART_API_TOKEN. "
+ "Please go to https://test.mermaidchart.com/app/user/settings and create one."
);
}

client = new MermaidChart({
clientID: '00000000-0000-0000-0000-000000git000test',
baseURL: 'https://test.mermaidchart.com',
redirectURI: 'https://localhost.invalid',
});

await client.setAccessToken(process.env.TEST_MERMAIDCHART_API_TOKEN);
});

describe('getUser', () => {
it("should get user", async() => {
const user = await client.getUser();

expect(user).toHaveProperty('emailAddress');
});
});

const documentMatcher = expect.objectContaining({
documentID: expect.any(String),
major: expect.any(Number),
minor: expect.any(Number),
});

describe("getDocument", () => {
it("should get publicly shared diagram", async() => {
const latestDocument = await client.getDocument({
// owned by [email protected]
documentID: '8bce727b-69b7-4f6e-a434-d578e2b363ff',
});

expect(latestDocument).toStrictEqual(documentMatcher);

const earliestDocument = await client.getDocument({
// owned by [email protected]
documentID: '8bce727b-69b7-4f6e-a434-d578e2b363ff',
major: 0,
minor: 1,
});

expect(earliestDocument).toStrictEqual(documentMatcher);
});

it("should throw 404 on unknown document", async() => {
let error: AxiosError | undefined = undefined;
try {
await client.getDocument({
documentID: '00000000-0000-0000-0000-0000deaddead',
});
} catch (err) {
error = err as AxiosError;
}

expect(error).toBeInstanceOf(AxiosError);
expect(error?.response?.status).toBe(404);
});
});
88 changes: 88 additions & 0 deletions packages/sdk/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MermaidChart } from './index.js';
import { AuthorizationData } from './types.js';

import { OAuth2Client } from '@badgateway/oauth2-client';

describe('MermaidChart', () => {
let client: MermaidChart;
beforeEach(() => {
vi.resetAllMocks();

vi.spyOn(OAuth2Client.prototype, 'request').mockImplementation(
async (endpoint: 'tokenEndpoint' | 'introspectionEndpoint', _body: Record<string, any>) => {
switch (endpoint) {
case 'tokenEndpoint':
return {
access_token: 'test-example-access_token',
refresh_token: 'test-example-refresh_token',
token_type: 'Bearer',
expires_in: 3600,
};
default:
throw new Error('mock unimplemented');
}
},
);

client = new MermaidChart({
clientID: '00000000-0000-0000-0000-000000000dead',
baseURL: 'https://test.mermaidchart.invalid',
redirectURI: 'https://localhost.invalid',
});

vi.spyOn(client, 'getUser').mockImplementation(async () => {
return {
fullName: 'Test User',
emailAddress: '[email protected]',
};
});
});

describe('#getAuthorizationData', () => {
it('should set default state', async () => {
const { state, url } = await client.getAuthorizationData();

expect(new URL(url).searchParams.has('state', state)).toBeTruthy();
});
});

describe('#handleAuthorizationResponse', () => {
let state: AuthorizationData['state'];
beforeEach(async () => {
({ state } = await client.getAuthorizationData({ state }));
});

it('should set token', async () => {
const code = 'hello-world';

await client.handleAuthorizationResponse(
`https://response.invalid?code=${code}&state=${state}`,
);
await expect(client.getAccessToken()).resolves.toBe('test-example-access_token');
});

it('should throw with invalid state', async () => {
await expect(() =>
client.handleAuthorizationResponse(
'https://response.invalid?code=hello-world&state=my-invalid-state',
),
).rejects.toThrowError('invalid_state');
});

it('should throw with nicer error if URL has no query params', async () => {
await expect(() =>
client.handleAuthorizationResponse(
// missing the ? so it's not read as a query
'code=hello-world&state=my-invalid-state',
),
).rejects.toThrowError(/no query parameters/);
});

it('should work in Node.JS with url fragment', async () => {
const code = 'hello-nodejs-world';
await client.handleAuthorizationResponse(`?code=${code}&state=${state}`);
await expect(client.getAccessToken()).resolves.toBe('test-example-access_token');
});
});
});
19 changes: 17 additions & 2 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export class MermaidChart {

const url = await this.oauth.authorizationCode.getAuthorizeUri({
redirectUri: this.redirectURI,
state,
state: stateID,
codeVerifier,
scope,
});
Expand All @@ -100,12 +100,20 @@ export class MermaidChart {
};
}

/**
* Handle authorization response.
*
* @param urlString - URL, only the query string is required (e.g. `?code=xxxx&state=xxxxx`)
*/
public async handleAuthorizationResponse(urlString: string) {
const url = new URL(urlString);
const url = new URL(urlString, 'https://dummy.invalid');
const state = url.searchParams.get('state') ?? undefined;
const authorizationToken = url.searchParams.get('code');

if (!authorizationToken) {
if (url.searchParams.size === 0) {
throw new Error(`URL ${JSON.stringify(urlString)} has no query parameters.`);
}
throw new RequiredParameterMissingError('token');
}
if (!state) {
Expand Down Expand Up @@ -180,6 +188,13 @@ export class MermaidChart {
return url;
}

public async getDocument(
document: Pick<MCDocument, 'documentID'> | Pick<MCDocument, 'documentID' | 'major' | 'minor'>,
) {
const {data} = await this.axios.get<MCDocument>(URLS.rest.documents.pick(document).self);
return data;
}

public async getRawDocument(
document: Pick<MCDocument, 'documentID' | 'major' | 'minor'>,
theme: 'light' | 'dark',
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ export interface MCProject {
export interface MCDocument {
documentID: string;
projectID: string;
major: string;
minor: string;
major: number;
minor: number;
title: string;
}

Expand Down
19 changes: 19 additions & 0 deletions packages/sdk/src/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ export const URLS = {
token: `/oauth/token`,
},
rest: {
documents: {
pick: (opts: Pick<MCDocument, 'documentID'> | Pick<MCDocument, 'documentID' | 'major' | 'minor'>) => {
const {documentID} = opts;
let queryParams = "";

if ('major' in opts) {
const {major, minor} = opts;

queryParams = `v${major ?? 0}.${minor ?? 1}`;
}

const baseURL = `/rest-api/documents/${documentID}`;
return {
presentations: `${baseURL}/presentations`,
self: baseURL,
withVersion: `${baseURL}${queryParams}`
};
}
},
users: {
self: `/rest-api/users/me`,
},
Expand Down
5 changes: 4 additions & 1 deletion packages/sdk/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
"outDir": "./dist",
"types": [
"node"
],
},
"include": ["./src/**/*.ts"]
}
Loading

0 comments on commit b13b1fe

Please sign in to comment.