Skip to content

Release 2.5.0 #37

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

Merged
merged 20 commits into from
Jun 18, 2025
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2a8db2a
chore; type addition
Taombawkry Aug 8, 2024
2dd5edb
Add retry logic with exponential backoff to API client
Taombawkry Aug 8, 2024
93a0060
feat: expand client options type and add credit check with retry logic
Taombawkry Sep 6, 2024
737366b
Merge pull request #30 from Taombawkry/develop
Yukaii Oct 11, 2024
b689ebb
fix: resolve test conflicts with retry logic …
bagnier Mar 28, 2025
4229fb1
security: update dependencies to fix vulnerabilities
bagnier Mar 28, 2025
3056cea
feat: add etag support to getNote method
bagnier Mar 28, 2025
844dded
feat: extend etag support to createNote and update methods
bagnier Mar 30, 2025
75dcf4d
Merge pull request #31 from bagnier/etag-support
Yukaii May 27, 2025
36c7e42
Merge pull request #33 from hackmdio/master
Yukaii May 27, 2025
5f076b6
chore: update CI workflow to include develop branch for push and pull…
Yukaii May 27, 2025
11c1cdf
chore: add GitHub Actions workflow for publishing to NPM (#35)
Yukaii May 27, 2025
e40f3db
docs: enhance README with advanced features including retry configura…
Yukaii May 27, 2025
c876ce0
chore: update package configuration for ESM support and add Rollup fo…
Yukaii May 27, 2025
ae090e6
fix: resolve issues in ESM build process and improve Rollup configura…
Yukaii May 27, 2025
41b683a
docs: update README to include build step for HackMD API package and …
Yukaii May 27, 2025
8177053
chore: add ESLint configuration and update dependencies for TypeScrip…
Yukaii May 27, 2025
7afaa6b
docs: add usage examples for Node.js in README to facilitate quick st…
Yukaii May 27, 2025
ed9db81
Merge pull request #36 from hackmdio/docs/update-readme
Yukaii May 27, 2025
2b304e0
chore: update version to 2.5.0 in package.json and package-lock.json
Yukaii May 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -2,9 +2,9 @@ name: Node.js CI

on:
push:
branches: [ master ]
branches: [ master, develop ]
pull_request:
branches: [ master ]
branches: [ master, develop ]

jobs:
test:
34 changes: 34 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: Publish to NPM

on:
push:
tags:
- 'v*'

jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: nodejs/package-lock.json

- name: Install dependencies
working-directory: nodejs
run: npm ci

- name: Build
working-directory: nodejs
run: npm run build

- name: Publish to NPM
working-directory: nodejs
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Environment variables
.env
.env.*
!.env.example
node_modules
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -6,6 +6,30 @@ This repository contains a set of packages for interacting with the [HackMD API]

See [README](./nodejs)

## Examples

To help you get started quickly, we provide comprehensive usage examples in the `examples/` directory:

### Node.js Example

The `examples/nodejs/` directory contains a complete example project demonstrating:

- User information retrieval
- Note creation and management
- ETag support for caching
- Content updates
- Error handling with retry logic
- Environment variable configuration

To run the Node.js example:

1. Navigate to the example directory: `cd examples/nodejs`
2. Follow the setup instructions in [examples/nodejs/README.md](./examples/nodejs/README.md)
3. Set your HackMD access token
4. Run `npm start`

The example includes detailed comments and demonstrates best practices for using the HackMD API client.

## LICENSE

MIT
1 change: 1 addition & 0 deletions examples/nodejs/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
HACKMD_ACCESS_TOKEN=YOUR_ACCESS_TOKEN
63 changes: 63 additions & 0 deletions examples/nodejs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# HackMD API Client Example

This is an example project demonstrating the usage of the HackMD API client.

## Setup

1. First, build the HackMD API package:
```bash
cd ../../nodejs
npm install
npm run build
cd ../examples/nodejs
```

2. Install the example dependencies:
```bash
npm install
```

3. Set up your HackMD access token using one of these methods:

a. Set it as an environment variable:
```bash
# For Unix/Linux/macOS
export HACKMD_ACCESS_TOKEN=your_access_token_here

# For Windows PowerShell
$env:HACKMD_ACCESS_TOKEN="your_access_token_here"
```

b. Or create a `.env` file in the project root (not tracked by git):
```
HACKMD_ACCESS_TOKEN=your_access_token_here
```

You can get your access token from [HackMD API documentation](https://hackmd.io/@hackmd-api/developer-portal).

## Running the Example

To run the example:

```bash
npm start
```

## What's Demonstrated

The example demonstrates several features of the HackMD API client:

1. Getting user information
2. Creating a new note
3. Using ETag support for caching
4. Updating note content
5. Getting raw response data
6. Deleting notes

## Features Shown

- Retry configuration with exponential backoff
- ETag support for caching
- Response data unwrapping
- Error handling
- Environment variable configuration
77 changes: 77 additions & 0 deletions examples/nodejs/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import HackMDAPI from '@hackmd/api';
import dotenv from 'dotenv';

// Load environment variables
dotenv.config();

// Check for required environment variable
if (!process.env.HACKMD_ACCESS_TOKEN) {
console.error('Error: HACKMD_ACCESS_TOKEN environment variable is not set.');
console.error('Please set your HackMD access token using one of these methods:');
console.error('1. Create a .env file with HACKMD_ACCESS_TOKEN=your_token_here');
console.error('2. Set the environment variable directly: export HACKMD_ACCESS_TOKEN=your_token_here');
process.exit(1);
}

// Create API client with retry configuration
const client = new HackMDAPI(process.env.HACKMD_ACCESS_TOKEN, 'https://api.hackmd.io/v1', {
retryConfig: {
maxRetries: 3,
baseDelay: 100
}
});

async function main() {
try {
// Example 1: Get user information
console.log('Getting user information...');
const me = await client.getMe();
console.log('User email:', me.email);
console.log('User name:', me.name);

// Example 2: Create a new note
console.log('\nCreating a new note...');
const newNote = await client.createNote({
title: 'Test Note',
content: '# Hello from HackMD API\n\nThis is a test note created using the API client.',
readPermission: 'guest',
writePermission: 'owner'
});
console.log('Created note ID:', newNote.id);
console.log('Note URL:', newNote.publishLink);

// Example 3: Get note with ETag support
console.log('\nGetting note with ETag support...');
const note = await client.getNote(newNote.id);
console.log('Note content:', note.content);

// Second request with ETag
const updatedNote = await client.getNote(newNote.id, { etag: note.etag });
console.log('Note status:', updatedNote.status);

// Example 4: Update note content
console.log('\nUpdating note content...');
const updatedContent = await client.updateNoteContent(newNote.id, '# Updated Content\n\nThis note has been updated!');
console.log('Updated note content:', updatedContent.content);

// Example 5: Get raw response (unwrapData: false)
console.log('\nGetting raw response...');
const rawResponse = await client.getNote(newNote.id, { unwrapData: false });
console.log('Response headers:', rawResponse.headers);
console.log('Response status:', rawResponse.status);

// Example 6: Delete the test note
console.log('\nCleaning up - deleting test note...');
await client.deleteNote(newNote.id);
console.log('Note deleted successfully');

} catch (error) {
console.error('Error:', error.message);
if (error.response) {
console.error('Response status:', error.response.status);
console.error('Response data:', error.response.data);
}
}
}

main();
57 changes: 57 additions & 0 deletions examples/nodejs/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions examples/nodejs/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"name": "hackmd-api-example",
"version": "1.0.0",
"description": "Example usage of HackMD API client",
"main": "index.js",
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"@hackmd/api": "file:../../nodejs",
"dotenv": "^16.4.5"
}
}
2 changes: 1 addition & 1 deletion nodejs/.eslintrc.js → nodejs/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -22,7 +22,7 @@ const config = {
]
},
"ignorePatterns": [
".eslintrc.js"
".eslintrc.cjs"
],
}

85 changes: 85 additions & 0 deletions nodejs/README.md
Original file line number Diff line number Diff line change
@@ -22,16 +22,101 @@ npm install @hackmd/api --save

## Example

### ES Modules (ESM)

```javascript
// Default import
import HackMDAPI from '@hackmd/api'

// Or named import
import { API } from '@hackmd/api'

const client = new HackMDAPI('YOUR_ACCESS_TOKEN' /* required */, 'https://api.hackmd.io/v1' /* optional */)

client.getMe().then(me => {
console.log(me.email)
})
```

### CommonJS

```javascript
// Default import
const HackMDAPI = require('@hackmd/api').default

// Or named import
const { API } = require('@hackmd/api')

const client = new HackMDAPI('YOUR_ACCESS_TOKEN', 'https://api.hackmd.io/v1')

client.getMe().then(me => {
console.log(me.email)
})
```

### Legacy Import Support

For backward compatibility, the package also supports legacy import paths:

```javascript
// ESM
import HackMDAPI from '@hackmd/api/dist'
import { API } from '@hackmd/api/dist'

// CommonJS
const HackMDAPI = require('@hackmd/api/dist').default
const { API } = require('@hackmd/api/dist')

// Direct file imports
import { API } from '@hackmd/api/dist/index.js'
```

## Advanced Features

### Retry Configuration

The client supports automatic retry for failed requests with exponential backoff. You can configure retry behavior when creating the client:

```javascript
const client = new HackMDAPI('YOUR_ACCESS_TOKEN', 'https://api.hackmd.io/v1', {
retryConfig: {
maxRetries: 3, // Maximum number of retry attempts
baseDelay: 100 // Base delay in milliseconds for exponential backoff
}
})
```

The client will automatically retry requests that fail with:
- 5xx server errors
- 429 Too Many Requests errors
- Network errors

### Response Data Handling

By default, the client automatically unwraps the response data from the Axios response object. You can control this behavior using the `unwrapData` option:

```javascript
// Get raw Axios response (includes headers, status, etc.)
const response = await client.getMe({ unwrapData: false })

// Get only the data (default behavior)
const data = await client.getMe({ unwrapData: true })
```

### ETag Support

The client supports ETag-based caching for note retrieval. You can pass an ETag to check if the content has changed:

```javascript
// First request
const note = await client.getNote('note-id')
const etag = note.etag

// Subsequent request with ETag
const updatedNote = await client.getNote('note-id', { etag })
// If the note hasn't changed, the response will have status 304
```

## API

See the [code](./src/index.ts) and [typings](./src/type.ts). The API client is written in TypeScript, so you can get auto-completion and type checking in any TypeScript Language Server powered editor or IDE.
5,314 changes: 2,790 additions & 2,524 deletions nodejs/package-lock.json

Large diffs are not rendered by default.

39 changes: 29 additions & 10 deletions nodejs/package.json
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
{
"name": "@hackmd/api",
"version": "2.4.0",
"version": "2.5.0",
"description": "HackMD Node.js API Client",
"main": "dist/index.js",
"declaration": "./dist/index.d.ts",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./dist": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./dist/*": "./dist/*"
},
"scripts": {
"clean": "rimraf dist",
"build": "npm run clean && tsc -p tsconfig.build.json -d",
"watch": "npm run clean && tsc -p tsconfig.build.json -w",
"build": "npm run clean && rollup -c",
"watch": "npm run clean && rollup -c -w",
"prepublishOnly": "npm run build",
"lint": "eslint src --fix --ext .ts",
"test": "jest"
@@ -29,22 +44,26 @@
"license": "MIT",
"devDependencies": {
"@faker-js/faker": "^7.6.0",
"@rollup/plugin-commonjs": "^28.0.3",
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@types/eslint": "^8.21.0",
"@types/jest": "^29.4.0",
"@types/node": "^13.11.1",
"@typescript-eslint/eslint-plugin": "^5.52.0",
"@typescript-eslint/parser": "^5.52.0",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"dotenv": "^16.0.3",
"eslint": "^8.9.0",
"eslint": "^8.57.1",
"jest": "^29.4.2",
"msw": "^1.0.1",
"msw": "^2.7.3",
"rimraf": "^4.1.2",
"rollup": "^4.41.1",
"ts-jest": "^29.0.5",
"ts-node": "^8.8.2",
"typescript": "^4.9.5"
},
"dependencies": {
"axios": "^0.25.0",
"axios": "^1.8.4",
"tslib": "^1.14.1"
}
}
49 changes: 49 additions & 0 deletions nodejs/rollup.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import typescript from '@rollup/plugin-typescript'

export default [
// ESM build
{
input: 'src/index.ts',
output: {
file: 'dist/index.js',
format: 'esm',
sourcemap: true,
},
plugins: [
resolve({
extensions: ['.ts', '.js']
}),
commonjs(),
typescript({
tsconfig: './tsconfig.build.json',
declaration: true,
declarationDir: './dist',
rootDir: './src'
})
],
external: ['axios']
},
// CJS build
{
input: 'src/index.ts',
output: {
file: 'dist/index.cjs',
format: 'cjs',
sourcemap: true,
exports: 'named'
},
plugins: [
resolve({
extensions: ['.ts', '.js']
}),
commonjs(),
typescript({
tsconfig: './tsconfig.build.json',
declaration: false // Only generate declarations once
})
],
external: ['axios']
}
]
19 changes: 5 additions & 14 deletions nodejs/src/error.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
class HackMDError extends Error {
export class HackMDError extends Error {
constructor (message: string) {
super(message)
Object.setPrototypeOf(this, new.target.prototype)
}
}


class HttpResponseError extends HackMDError {
export class HttpResponseError extends HackMDError {
public constructor (
message: string,
readonly code: number,
@@ -16,9 +15,9 @@ class HttpResponseError extends HackMDError {
}
}

class MissingRequiredArgument extends HackMDError {}
class InternalServerError extends HttpResponseError {}
class TooManyRequestsError extends HttpResponseError {
export class MissingRequiredArgument extends HackMDError {}
export class InternalServerError extends HttpResponseError {}
export class TooManyRequestsError extends HttpResponseError {
public constructor (
message: string,
readonly code: number,
@@ -30,11 +29,3 @@ class TooManyRequestsError extends HttpResponseError {
super(message, code, statusText)
}
}

export {
HackMDError,
HttpResponseError,
MissingRequiredArgument,
InternalServerError,
TooManyRequestsError,
}
98 changes: 83 additions & 15 deletions nodejs/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import axios, { AxiosInstance, AxiosRequestConfig, AxiosError, AxiosResponse } from 'axios'
import axios, { AxiosInstance, AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { CreateNoteOptions, GetMe, GetUserHistory, GetUserNotes, GetUserNote, CreateUserNote, GetUserTeams, GetTeamNotes, CreateTeamNote, SingleNote } from './type'
import * as HackMDErrors from './error'

export type RequestOptions = {
unwrapData?: boolean
unwrapData?: boolean;
etag?: string | undefined;
}

const defaultOption: RequestOptions = {
@@ -13,13 +14,29 @@ const defaultOption: RequestOptions = {
type OptionReturnType<Opt, T> = Opt extends { unwrapData: false } ? AxiosResponse<T> : Opt extends { unwrapData: true } ? T : T

export type APIClientOptions = {
wrapResponseErrors: boolean
wrapResponseErrors: boolean;
timeout?: number;
retryConfig?: {
maxRetries: number;
baseDelay: number;
};
}

export class API {
private axios: AxiosInstance

constructor (readonly accessToken: string, public hackmdAPIEndpointURL: string = "https://api.hackmd.io/v1", public options: APIClientOptions = { wrapResponseErrors: true }) {
constructor (
readonly accessToken: string,
public hackmdAPIEndpointURL: string = "https://api.hackmd.io/v1",
public options: APIClientOptions = {
wrapResponseErrors: true,
timeout: 30000,
retryConfig: {
maxRetries: 3,
baseDelay: 100,
},
}
) {
if (!accessToken) {
throw new HackMDErrors.MissingRequiredArgument('Missing access token when creating HackMD client')
}
@@ -28,12 +45,13 @@ export class API {
baseURL: hackmdAPIEndpointURL,
headers:{
"Content-Type": "application/json",
}
},
timeout: options.timeout
})

this.axios.interceptors.request.use(
(config: AxiosRequestConfig) =>{
config.headers!.Authorization = `Bearer ${accessToken}`
(config: InternalAxiosRequestConfig) => {
config.headers.set('Authorization', `Bearer ${accessToken}`)
return config
},
(err: AxiosError) => {
@@ -76,8 +94,46 @@ export class API {
}
)
}
if (options.retryConfig) {
this.createRetryInterceptor(this.axios, options.retryConfig.maxRetries, options.retryConfig.baseDelay)
}
}

private exponentialBackoff (retries: number, baseDelay: number): number {
return Math.pow(2, retries) * baseDelay
}

private isRetryableError (error: AxiosError): boolean {
return (
!error.response ||
(error.response.status >= 500 && error.response.status < 600) ||
error.response.status === 429
)
}

private createRetryInterceptor (axiosInstance: AxiosInstance, maxRetries: number, baseDelay: number): void {
let retryCount = 0

axiosInstance.interceptors.response.use(
response => response,
async error => {
if (retryCount < maxRetries && this.isRetryableError(error)) {
const remainingCredits = parseInt(error.response?.headers['x-ratelimit-userremaining'], 10)

if (isNaN(remainingCredits) || remainingCredits > 0) {
retryCount++
const delay = this.exponentialBackoff(retryCount, baseDelay)
console.warn(`Retrying request... attempt #${retryCount} after delay of ${delay}ms`)
await new Promise(resolve => setTimeout(resolve, delay))
return axiosInstance(error.config)
}
}

retryCount = 0 // Reset retry count after a successful request or when not retrying
return Promise.reject(error)
}
)
}
async getMe<Opt extends RequestOptions> (options = defaultOption as Opt): Promise<OptionReturnType<Opt, GetMe>> {
return this.unwrapData(this.axios.get<GetMe>("me"), options.unwrapData) as unknown as OptionReturnType<Opt, GetMe>
}
@@ -91,19 +147,26 @@ export class API {
}

async getNote<Opt extends RequestOptions> (noteId: string, options = defaultOption as Opt): Promise<OptionReturnType<Opt, GetUserNote>> {
return this.unwrapData(this.axios.get<GetUserNote>(`notes/${noteId}`), options.unwrapData) as unknown as OptionReturnType<Opt, GetUserNote>
// Prepare request config with etag if provided in options
const config = options.etag ? {
headers: { 'If-None-Match': options.etag },
// Consider 304 responses as successful
validateStatus: (status: number) => (status >= 200 && status < 300) || status === 304
} : undefined
const request = this.axios.get<GetUserNote>(`notes/${noteId}`, config)
return this.unwrapData(request, options.unwrapData, true) as unknown as OptionReturnType<Opt, GetUserNote>
}

async createNote<Opt extends RequestOptions> (payload: CreateNoteOptions, options = defaultOption as Opt): Promise<OptionReturnType<Opt, CreateUserNote>> {
return this.unwrapData(this.axios.post<CreateUserNote>("notes", payload), options.unwrapData) as unknown as OptionReturnType<Opt, CreateUserNote>
return this.unwrapData(this.axios.post<CreateUserNote>("notes", payload), options.unwrapData, true) as unknown as OptionReturnType<Opt, CreateUserNote>
}

async updateNoteContent<Opt extends RequestOptions> (noteId: string, content?: string, options = defaultOption as Opt): Promise<OptionReturnType<Opt, SingleNote>> {
return this.unwrapData(this.axios.patch<SingleNote>(`notes/${noteId}`, { content }), options.unwrapData) as unknown as OptionReturnType<Opt, SingleNote>
return this.unwrapData(this.axios.patch<SingleNote>(`notes/${noteId}`, { content }), options.unwrapData, true) as unknown as OptionReturnType<Opt, SingleNote>
}

async updateNote<Opt extends RequestOptions> (noteId: string, payload: Partial<Pick<SingleNote, 'content' | 'readPermission' | 'writePermission' | 'permalink'>>, options = defaultOption as Opt): Promise<OptionReturnType<Opt, SingleNote>> {
return this.unwrapData(this.axios.patch<SingleNote>(`notes/${noteId}`, payload), options.unwrapData) as unknown as OptionReturnType<Opt, SingleNote>
return this.unwrapData(this.axios.patch<SingleNote>(`notes/${noteId}`, payload), options.unwrapData, true) as unknown as OptionReturnType<Opt, SingleNote>
}

async deleteNote<Opt extends RequestOptions> (noteId: string, options = defaultOption as Opt): Promise<OptionReturnType<Opt, SingleNote>> {
@@ -134,12 +197,17 @@ export class API {
return this.axios.delete<AxiosResponse>(`teams/${teamPath}/notes/${noteId}`)
}

private unwrapData<T> (reqP: Promise<AxiosResponse<T>>, unwrap = true) {
if (unwrap) {
return reqP.then(response => response.data)
} else {
private unwrapData<T> (reqP: Promise<AxiosResponse<T>>, unwrap = true, includeEtag = false) {
if (!unwrap) {
// For raw responses, etag is available via response.headers
return reqP
}
return reqP.then(response => {
const data = response.data
if (!includeEtag) return data
const etag = response.headers.etag || response.headers['ETag']
return { ...data, status: response.status, etag }
})
}
}

54 changes: 33 additions & 21 deletions nodejs/tests/api.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { server } from './mock'
import { API } from '../src'
import { rest } from 'msw'
import { http, HttpResponse } from 'msw'
import { TooManyRequestsError } from '../src/error'

let client: API
@@ -16,7 +16,9 @@ afterEach(() => {
})

afterAll(() => {
return server.close()
server.close()
// Add explicit cleanup to ensure Jest exits properly
return new Promise(resolve => setTimeout(resolve, 100))
})

test('getMe', async () => {
@@ -43,9 +45,11 @@ test('should throw axios error object if set wrapResponseErrors to false', async
})

server.use(
rest.get('https://api.hackmd.io/v1/me', (req, res, ctx) => {
return res(ctx.status(429))
}),
http.get('https://api.hackmd.io/v1/me', () => {
return new HttpResponse(null, {
status: 429
})
})
)

try {
@@ -56,29 +60,37 @@ test('should throw axios error object if set wrapResponseErrors to false', async
}
})

test.only('should throw HackMD error object', async () => {
test('should throw HackMD error object', async () => {
// Create a client with retry disabled to avoid conflicts with error handling test
const clientWithoutRetry = new API(process.env.HACKMD_ACCESS_TOKEN!, undefined, {
wrapResponseErrors: true,
retryConfig: undefined // Disable retry logic for this test
})

server.use(
rest.get('https://api.hackmd.io/v1/me', (req, res, ctx) => {
return res(
ctx.status(429),
ctx.set({
'X-RateLimit-UserLimit': '100',
'x-RateLimit-UserRemaining': '0',
'x-RateLimit-UserReset': String(
new Date().getTime() + 1000 * 60 * 60 * 24,
),
}),
http.get('https://api.hackmd.io/v1/me', () => {
return HttpResponse.json(
{},
{
status: 429,
headers: {
'X-RateLimit-UserLimit': '100',
'x-RateLimit-UserRemaining': '0',
'x-RateLimit-UserReset': String(
new Date().getTime() + 1000 * 60 * 60 * 24,
),
},
}
)
}),
})
)

try {
await client.getMe()
await clientWithoutRetry.getMe()
// If we get here, the test should fail because an error wasn't thrown
expect('no error thrown').toBe('error should have been thrown')
} catch (error: any) {
expect(error).toBeInstanceOf(TooManyRequestsError)

console.log(JSON.stringify(error))

expect(error).toHaveProperty('code', 429)
expect(error).toHaveProperty('statusText', 'Too Many Requests')
expect(error).toHaveProperty('userLimit', 100)
272 changes: 272 additions & 0 deletions nodejs/tests/etag.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
import { server } from './mock'
import { API } from '../src'
import { http, HttpResponse } from 'msw'

let client: API

beforeAll(() => {
client = new API(process.env.HACKMD_ACCESS_TOKEN!)
return server.listen()
})

afterEach(() => {
server.resetHandlers()
})

afterAll(() => {
server.close()
// Add explicit cleanup to ensure Jest exits properly
return new Promise(resolve => setTimeout(resolve, 100))
})

describe('Etag support', () => {
// Helper to reset server between tests
beforeEach(() => {
server.resetHandlers()
})

describe('getNote', () => {
test('should include etag property in response when unwrapData is true', async () => {
// Setup mock server to return an etag
const mockEtag = 'W/"123456789"'

server.use(
http.get('https://api.hackmd.io/v1/notes/test-note-id', () => {
return HttpResponse.json(
{
id: 'test-note-id',
title: 'Test Note'
},
{
headers: {
'ETag': mockEtag
}
}
)
})
)

// Make request with default unwrapData: true
const response = await client.getNote('test-note-id')

// Verify response has etag property
expect(response).toHaveProperty('etag', mockEtag)

// Verify data properties still exist
expect(response).toHaveProperty('id', 'test-note-id')
expect(response).toHaveProperty('title', 'Test Note')
})

test('should include etag in response headers when unwrapData is false', async () => {
// Setup mock server to return an etag
const mockEtag = 'W/"123456789"'

server.use(
http.get('https://api.hackmd.io/v1/notes/test-note-id', () => {
return HttpResponse.json(
{
id: 'test-note-id',
title: 'Test Note'
},
{
headers: {
'ETag': mockEtag
}
}
)
})
)

// Make request with unwrapData: false
const response = await client.getNote('test-note-id', { unwrapData: false })

// Verify response headers contain etag
expect(response.headers.etag).toBe(mockEtag)

// Verify data is in response.data
expect(response.data).toHaveProperty('id', 'test-note-id')
expect(response.data).toHaveProperty('title', 'Test Note')
})

test('should send etag in If-None-Match header when provided in options', async () => {
// Setup mock server to check for If-None-Match header
let ifNoneMatchValue: string | null = null
const mockEtag = 'W/"123456789"'

server.use(
http.get('https://api.hackmd.io/v1/notes/test-note-id', ({ request }) => {
// Store the If-None-Match header value for verification
ifNoneMatchValue = request.headers.get('If-None-Match')

return HttpResponse.json(
{
id: 'test-note-id',
title: 'Test Note'
},
{
headers: {
'ETag': mockEtag
}
}
)
})
)

// Make request with etag in options
await client.getNote('test-note-id', { etag: mockEtag })

// Verify the If-None-Match header was sent with correct value
expect(ifNoneMatchValue).toBe(mockEtag)
})

test('should preserve 304 status and etag when unwrapData is false and content not modified', async () => {
// Setup mock server to return 304 when etag matches
const mockEtag = 'W/"123456789"'

server.use(
http.get('https://api.hackmd.io/v1/notes/test-note-id', ({ request }) => {
const ifNoneMatch = request.headers.get('If-None-Match')

// Return 304 when etag matches
if (ifNoneMatch === mockEtag) {
return new HttpResponse(null, {
status: 304,
headers: {
'ETag': mockEtag
}
})
}

return HttpResponse.json(
{
id: 'test-note-id',
title: 'Test Note'
},
{
headers: {
'ETag': mockEtag
}
}
)
})
)

// Request with unwrapData: false to get full response including status
const response = await client.getNote('test-note-id', { etag: mockEtag, unwrapData: false })

// Verify we get a 304 status code
expect(response.status).toBe(304)

// Verify etag is still available in headers
expect(response.headers.etag).toBe(mockEtag)
})

test('should return status and etag only when unwrapData is true and content not modified', async () => {
// Setup mock server to return 304 when etag matches
const mockEtag = 'W/"123456789"'

server.use(
http.get('https://api.hackmd.io/v1/notes/test-note-id', ({ request }) => {
const ifNoneMatch = request.headers.get('If-None-Match')

// Return 304 when etag matches
if (ifNoneMatch === mockEtag) {
return new HttpResponse(null, {
status: 304,
headers: {
'ETag': mockEtag
}
})
}

return HttpResponse.json(
{
id: 'test-note-id',
title: 'Test Note'
},
{
headers: {
'ETag': mockEtag
}
}
)
})
)

// Request with default unwrapData: true
const response = await client.getNote('test-note-id', { etag: mockEtag })

// With unwrapData: true and a 304 response, we just get the etag
expect(response).toHaveProperty('etag', mockEtag)
expect(response).toHaveProperty('status', 304)
})
})

describe('createNote', () => {
test('should include etag property in response when creating a note', async () => {
// Setup mock server to return an etag
const mockEtag = 'W/"abcdef123"'

server.use(
http.post('https://api.hackmd.io/v1/notes', () => {
return HttpResponse.json(
{
id: 'new-note-id',
title: 'New Test Note'
},
{
headers: {
'ETag': mockEtag
}
}
)
})
)

// Make request with default unwrapData: true
const response = await client.createNote({ title: 'New Test Note', content: 'Test content' })

// Verify response has etag property
expect(response).toHaveProperty('etag', mockEtag)

// Verify data properties still exist
expect(response).toHaveProperty('id', 'new-note-id')
expect(response).toHaveProperty('title', 'New Test Note')
})
})

describe('updateNote', () => {
test('should include etag property in response when updating note content', async () => {
// Setup mock server to return an etag
const mockEtag = 'W/"updated-etag"'

server.use(
http.patch('https://api.hackmd.io/v1/notes/test-note-id', () => {
return HttpResponse.json(
{
id: 'test-note-id',
title: 'Updated Test Note',
content: 'Updated content via updateNote'
},
{
headers: {
'ETag': mockEtag
}
}
)
})
)

// Make request with default unwrapData: true
const response = await client.updateNoteContent('test-note-id', 'Updated content')

// Verify response has etag property
expect(response).toHaveProperty('etag', mockEtag)

// Verify data properties still exist
expect(response).toHaveProperty('id', 'test-note-id')
expect(response).toHaveProperty('title', 'Updated Test Note')
expect(response).toHaveProperty('content', 'Updated content via updateNote')
})
})
})
8 changes: 3 additions & 5 deletions nodejs/tests/mock/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { rest } from 'msw'
import { http, HttpResponse } from 'msw'

export const handlers = [
rest.get('/posts', (req, res, ctx) => {
return res(
ctx.json(null),
)
http.get('/posts', () => {
return HttpResponse.json(null)
}),
]
55 changes: 21 additions & 34 deletions nodejs/tests/mock/index.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,33 @@
import { rest } from 'msw'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'
import { faker } from '@faker-js/faker'

const checkBearerToken = (req: any) => {
const authHeader = req.headers.get('Authorization')
const checkBearerToken = (request: Request) => {
const authHeader = request.headers.get('Authorization')
const token = authHeader?.split(' ')[1]

return token === process.env.HACKMD_ACCESS_TOKEN
}

type RestGetParameters = Parameters<typeof rest['get']>;
type RestResponseResolver = RestGetParameters[1];

const withAuthorization = (resolver: RestResponseResolver) => {
return (req: any, res: any, ctx: any) => {
if (!checkBearerToken(req)) {
return res(
ctx.status(401),
ctx.json({
error: 'Unauthorized',
}),
// In MSW v2, we don't need the withAuthorization wrapper - we can handle auth directly in the handler
export const server = setupServer(
http.get('https://api.hackmd.io/v1/me', ({ request }) => {
// Check authorization
if (!checkBearerToken(request)) {
return HttpResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
} else {
return resolver(req, res, ctx)
}
}
}

export const server = setupServer(
rest.get(
'https://api.hackmd.io/v1/me',
withAuthorization((req, res, ctx) => {
return res(
ctx.json({
id: faker.datatype.uuid(),
name: faker.name.fullName(),
email: faker.internet.email(),
userPath: faker.internet.userName(),
photo: faker.image.avatar(),
teams: []
}),
)
}),
),
// Return successful response with mock user data
return HttpResponse.json({
id: faker.datatype.uuid(),
name: faker.name.fullName(),
email: faker.internet.email(),
userPath: faker.internet.userName(),
photo: faker.image.avatar(),
teams: []
})
}),
)
5 changes: 3 additions & 2 deletions nodejs/tsconfig.base.json
Original file line number Diff line number Diff line change
@@ -2,11 +2,12 @@
"compilerOptions": {
"declaration": true,
"importHelpers": true,
"module": "commonjs",
"module": "ESNext",
"moduleResolution": "node",
"outDir": "dist",
"rootDir": ".",
"strict": true,
"target": "es2017",
"target": "ESNext",
"esModuleInterop": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true