Skip to content

Commit

Permalink
v3 improvements & fixes (#26)
Browse files Browse the repository at this point in the history
* Better error handling & switch to fetch instead of axios

* switch to cross-fetch

* protocol -> scheme breaking change

* better type protection around short url creation

* lightweight builds

* Adjust cjs and browser builds

* v3.0.0-rc2

* use .mjs suffix

* v3.0.0-rc3

* remove esm warning

* bump version

* More precise typing for toFile
  • Loading branch information
typpo committed Feb 13, 2022
1 parent 1ecba65 commit c53960f
Show file tree
Hide file tree
Showing 8 changed files with 160 additions and 77 deletions.
6 changes: 2 additions & 4 deletions README.md
Expand Up @@ -28,8 +28,6 @@ myChart.setConfig({
});
```

If you are using ESM modules, import the ESM build directly with: `import QuickChart from "quickchart-js/build/quickchart.esm.js"`

Use `getUrl()` on your quickchart object to get the encoded URL that renders your chart:

```js
Expand Down Expand Up @@ -123,9 +121,9 @@ Creates a binary buffer that contains your chart image.

Returns a base 64 data URL beginning with `data:image/png;base64`.

### toFile(pathOrDescriptor: string): Promise
### toFile(pathOrDescriptor: PathLike | FileHandle): Promise

Creates a file containing your chart image.
Given a filepath string or a writable file handle, creates a file containing your chart image.

## More examples

Expand Down
2 changes: 1 addition & 1 deletion examples/to_file_example.js
Expand Up @@ -16,4 +16,4 @@ async function saveChart() {
}
saveChart();

console.log('Written to /tmp/chart.png');
console.log('Written to /tmp/chart.png');
29 changes: 22 additions & 7 deletions package.json
@@ -1,11 +1,18 @@
{
"name": "quickchart-js",
"version": "2.0.3",
"version": "3.0.0",
"description": "Javascript client for QuickChart.io",
"main": "build/quickchart.cjs.js",
"module": "build/quickchart.esm.js",
"module": "build/quickchart.mjs",
"browser": "build/quickchart.js",
"types": "build/typescript/index.d.ts",
"exports": {
".": {
"require": "./build/quickchart.cjs.js",
"import": "./build/quickchart.mjs",
"types": "build/typescript/index.d.ts"
}
},
"repository": "https://github.com/typpo/quickchart-js",
"author": "Ian Webster",
"license": "MIT",
Expand All @@ -14,13 +21,13 @@
"test": "jest --testPathIgnorePatterns=examples/",
"format": "prettier --single-quote --trailing-comma all --print-width 100 --write \"**/*.{js,ts}\"",
"build": "tsc && yarn build:browser && yarn build:esm && yarn build:cjs",
"build:browser": "esbuild src/index.cjs.ts --bundle --sourcemap --external:fs --external:crypto --target=es2015 --global-name=QuickChart --tsconfig=tsconfig.json --outfile=build/quickchart.js",
"build:cjs": "esbuild src/index.cjs.ts --bundle --sourcemap --platform=node --target=node10.4 --global-name=QuickChart --tsconfig=tsconfig.json --outfile=build/quickchart.cjs.js",
"build:esm": "esbuild src/index.ts --bundle --sourcemap --platform=node --target=node10.4 --global-name=QuickChart --tsconfig=tsconfig.json --format=esm --outfile=build/quickchart.esm.js",
"build:watch": "esbuild src/index.cjs.ts --watch --bundle --sourcemap --platform=node --target=node10.4 --global-name=QuickChart --tsconfig=tsconfig.json --outfile=build/quickchart.cjs.js"
"build:browser": "esbuild src/index.ts --bundle --sourcemap --external:fs --external:crypto --target=es2015 --global-name=QuickChart --tsconfig=tsconfig.json --footer:js=\"QuickChart = QuickChart.default\" --outfile=build/quickchart.js",
"build:cjs": "esbuild src/index.ts --format=cjs --sourcemap --tree-shaking=true --platform=node --target=node10.4 --global-name=QuickChart --tsconfig=tsconfig.json --footer:js=\"module.exports = module.exports.default;\" --outfile=build/quickchart.cjs.js",
"build:esm": "esbuild src/index.ts --sourcemap --tree-shaking=true --platform=node --target=node10.4 --global-name=QuickChart --tsconfig=tsconfig.json --format=esm --outfile=build/quickchart.mjs",
"build:watch": "esbuild src/index.ts --watch --sourcemap --tree-shaking=true --platform=node --target=node10.4 --global-name=QuickChart --tsconfig=tsconfig.json --footer:js=\"module.exports = module.exports.default;\" --outfile=build/quickchart.cjs.js"
},
"dependencies": {
"axios": "^0.24.0",
"cross-fetch": "^3.1.5",
"javascript-stringify": "^2.1.0"
},
"devDependencies": {
Expand All @@ -30,7 +37,15 @@
"@types/jest": "^27.0.3",
"esbuild": "^0.14.1",
"jest": "^27.4.3",
"jest-fetch-mock": "^3.0.3",
"prettier": "^2.3.2",
"typescript": "^4.5.2"
},
"jest": {
"automock": false,
"resetMocks": false,
"setupFiles": [
"./test/setupJest.js"
]
}
}
3 changes: 0 additions & 3 deletions src/index.cjs.ts

This file was deleted.

51 changes: 34 additions & 17 deletions src/index.ts
@@ -1,7 +1,10 @@
import axios from 'axios';
import fetch from 'cross-fetch';
import { stringify } from 'javascript-stringify';

import type { PathLike } from 'fs';
import type { FileHandle } from 'fs/promises';
import type { ChartConfiguration } from 'chart.js';
import type { Response } from 'cross-fetch';

const SPECIAL_FUNCTION_REGEX: RegExp = /['"]__BEGINFUNCTION__(.*?)__ENDFUNCTION__['"]/g;

Expand Down Expand Up @@ -34,9 +37,17 @@ function doStringify(chartConfig: ChartConfiguration): string | undefined {
return str.replace(SPECIAL_FUNCTION_REGEX, '$1');
}

function postJson(url: string, payload: PostData): Promise<Response> {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
}

class QuickChart {
private host: string;
private protocol: string;
private scheme: string;
private baseUrl: string;
private width: number;
private height: number;
Expand All @@ -54,8 +65,8 @@ class QuickChart {
this.accountId = accountId;

this.host = 'quickchart.io';
this.protocol = 'https';
this.baseUrl = `${this.protocol}://${this.host}`;
this.scheme = 'https';
this.baseUrl = `${this.scheme}://${this.host}`;

this.chart = undefined;
this.width = 500;
Expand Down Expand Up @@ -193,13 +204,18 @@ class QuickChart {
throw new Error('Short URLs must use quickchart.io host');
}

const resp = await axios.post(`${this.baseUrl}/chart/create`, this.getPostData());
if (resp.status !== 200) {
throw `Bad response code ${resp.status} from chart shorturl endpoint`;
} else if (!resp.data.success) {
throw 'Received failure response from chart shorturl endpoint';
const resp = await postJson(`${this.baseUrl}/chart/create`, this.getPostData());
if (!resp.ok) {
const quickchartError = resp.headers.get('x-quickchart-error');
const details = quickchartError ? `\n${quickchartError}` : '';
throw new Error(`Chart shorturl creation failed with status code ${resp.status}${details}`);
}

const json = (await resp.json()) as undefined | { success?: boolean; url?: string };
if (!json || !json.success || !json.url) {
throw new Error('Received failure response from chart shorturl endpoint');
} else {
return resp.data.url;
return json.url;
}
}

Expand All @@ -208,13 +224,14 @@ class QuickChart {
throw new Error('You must call setConfig before getUrl');
}

const resp = await axios.post(`${this.baseUrl}/chart`, this.getPostData(), {
responseType: 'arraybuffer',
});
if (resp.status !== 200) {
throw `Bad response code ${resp.status} from chart shorturl endpoint`;
const resp = await postJson(`${this.baseUrl}/chart`, this.getPostData());
if (!resp.ok) {
const quickchartError = resp.headers.get('x-quickchart-error');
const details = quickchartError ? `\n${quickchartError}` : '';
throw new Error(`Chart creation failed with status code ${resp.status}${details}`);
}
return Buffer.from(resp.data, 'binary');
const data = await resp.arrayBuffer();
return Buffer.from(data);
}

async toDataUrl(): Promise<string> {
Expand All @@ -224,7 +241,7 @@ class QuickChart {
return `data:image/${type};base64,${b64buf}`;
}

async toFile(pathOrDescriptor: string): Promise<void> {
async toFile(pathOrDescriptor: PathLike | FileHandle): Promise<void> {
const fs = require('fs');
const buf = await this.toBinary();
fs.writeFileSync(pathOrDescriptor, buf);
Expand Down
91 changes: 53 additions & 38 deletions test/index.test.ts
@@ -1,9 +1,7 @@
import axios from 'axios';
import fetchMock from 'jest-fetch-mock';

import QuickChart from '../src/index';

jest.mock('axios');

test('basic chart, no auth', () => {
const qc = new QuickChart();
qc.setConfig({
Expand Down Expand Up @@ -249,82 +247,100 @@ test('postdata for js chart', () => {

test('getShortUrl for chart, no auth', async () => {
const mockResp = {
status: 200,
data: {
success: true,
url: 'https://quickchart.io/chart/render/9a560ba4-ab71-4d1e-89ea-ce4741e9d232',
},
success: true,
url: 'https://quickchart.io/chart/render/9a560ba4-ab71-4d1e-89ea-ce4741e9d232',
};
(axios.post as jest.Mock).mockImplementationOnce(() => Promise.resolve(mockResp));
fetchMock.mockResponseOnce(JSON.stringify(mockResp));

const qc = new QuickChart();
qc.setConfig({
type: 'bar',
data: { labels: ['Hello world', 'Foo bar'], datasets: [{ label: 'Foo', data: [1, 2] }] },
});

await expect(qc.getShortUrl()).resolves.toEqual(mockResp.url);
});

test('getShortUrl for chart js error', async () => {
fetchMock.mockResponseOnce(() => {
throw new Error('Request timed out');
});

const qc = new QuickChart();
qc.setConfig({
type: 'bar',
data: { labels: ['Hello world', 'Foo bar'], datasets: [{ label: 'Foo', data: [1, 2] }] },
});

await expect(qc.getShortUrl()).resolves.toEqual(mockResp.data.url);
expect(axios.post).toHaveBeenCalled();
await expect(qc.getShortUrl()).rejects.toThrow('Request timed out');
});

test('getShortUrl for chart bad status code', async () => {
const mockResp = {
fetchMock.mockResponseOnce('', {
status: 502,
};
(axios.post as jest.Mock).mockImplementationOnce(() => Promise.resolve(mockResp));
});

const qc = new QuickChart();
qc.setConfig({
type: 'bar',
data: { labels: ['Hello world', 'Foo bar'], datasets: [{ label: 'Foo', data: [1, 2] }] },
});

await expect(qc.getShortUrl()).rejects.toThrow('failed with status code');
});

test('getShortUrl for chart bad status code with error detail', async () => {
fetchMock.mockResponseOnce('', {
status: 400,
headers: {
'x-quickchart-error': 'foo bar',
},
});

const qc = new QuickChart();
qc.setConfig({
type: 'bar',
data: { labels: ['Hello world', 'Foo bar'], datasets: [{ label: 'Foo', data: [1, 2] }] },
});

await expect(qc.getShortUrl()).rejects.toContain('Bad response code');
expect(axios.post).toHaveBeenCalled();
await expect(qc.getShortUrl()).rejects.toThrow('foo bar');
});

test('getShortUrl api failure', async () => {
const mockResp = {
status: 200,
data: {
fetchMock.mockResponseOnce(
JSON.stringify({
success: false,
},
};
(axios.post as jest.Mock).mockImplementationOnce(() => Promise.resolve(mockResp));
}),
);

const qc = new QuickChart();
qc.setConfig({
type: 'bar',
data: { labels: ['Hello world', 'Foo bar'], datasets: [{ label: 'Foo', data: [1, 2] }] },
});

await expect(qc.getShortUrl()).rejects.toContain('failure response');
expect(axios.post).toHaveBeenCalled();
await expect(qc.getShortUrl()).rejects.toThrow('failure response');
expect(fetch).toHaveBeenCalled();
});

test('toBinary, no auth', async () => {
const mockResp = {
status: 200,
data: Buffer.from('bWVvdw==', 'base64'),
};
(axios.post as jest.Mock).mockImplementationOnce(() => Promise.resolve(mockResp));
const mockData = Buffer.from('bWVvdw==', 'base64');
// https://github.com/jefflau/jest-fetch-mock/issues/218
fetchMock.mockResponseOnce(() => Promise.resolve({ body: mockData as unknown as string }));

const qc = new QuickChart();
qc.setConfig({
type: 'bar',
data: { labels: ['Hello world', 'Foo bar'], datasets: [{ label: 'Foo', data: [1, 2] }] },
});

await expect(qc.toBinary()).resolves.toEqual(mockResp.data);
expect(axios.post).toHaveBeenCalled();
await expect(qc.toBinary()).resolves.toEqual(mockData);
});

test('toBinary, no auth', async () => {
const mockResp = {
status: 200,
data: Buffer.from('bWVvdw==', 'base64'),
};
(axios.post as jest.Mock).mockImplementationOnce(() => Promise.resolve(mockResp));
test('toDataUrl, no auth', async () => {
fetchMock.mockResponseOnce(() =>
Promise.resolve({ body: Buffer.from('bWVvdw==', 'base64') as unknown as string }),
);

const qc = new QuickChart();
qc.setConfig({
Expand All @@ -333,7 +349,6 @@ test('toBinary, no auth', async () => {
});

await expect(qc.toDataUrl()).resolves.toEqual('data:image/png;base64,bWVvdw==');
expect(axios.post).toHaveBeenCalled();
});

test('no chart specified throws error', async () => {
Expand Down
3 changes: 3 additions & 0 deletions test/setupJest.js
@@ -0,0 +1,3 @@
const fetchMock = require('jest-fetch-mock');
fetchMock.enableMocks();
jest.setMock('cross-fetch', fetchMock);

0 comments on commit c53960f

Please sign in to comment.