Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 36 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Optional live API checks. Add repository secret HACKMD_E2E_ACCESS_TOKEN.
# Optionally add HACKMD_E2E_API_ENDPOINT (e.g. https://api-stage.hackmd.io/v1); otherwise production is used.

name: E2E (live HackMD API)

on:
workflow_dispatch:

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

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

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

- name: Run e2e tests
working-directory: nodejs
env:
HACKMD_ACCESS_TOKEN: ${{ secrets.HACKMD_E2E_ACCESS_TOKEN }}
HACKMD_API_ENDPOINT: ${{ secrets.HACKMD_E2E_API_ENDPOINT }}
run: |
if [ -z "${HACKMD_ACCESS_TOKEN:-}" ]; then
echo "::error::Add repository secret HACKMD_E2E_ACCESS_TOKEN (a valid API token for the target environment)."
exit 1
fi
npm run test:e2e
33 changes: 33 additions & 0 deletions nodejs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,39 @@ const updatedNote = await client.getNote('note-id', { etag })

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.

## E2E tests (live API)

Integration tests call a real HackMD API (staging or production). They are **not** run by `npm test` or the default CI job.

**Requirements**

- `HACKMD_ACCESS_TOKEN` — a valid personal access token for the environment you target.
- Optional: `HACKMD_API_ENDPOINT` — defaults to `https://api.hackmd.io/v1`. For staging, use `https://api-stage.hackmd.io/v1`.

**Read-only (default e2e)**

```bash
cd nodejs
export HACKMD_ACCESS_TOKEN=your_token
export HACKMD_API_ENDPOINT=https://api-stage.hackmd.io/v1 # optional
npm run test:e2e
```

**With CRUD / mutations**

Set `HACKMD_E2E_MUTATIONS=1` to run write tests against your account:

- **Notes:** create → get → update (title, content, tags) → list → delete.
- **Folders:** one integration test runs create (root + nested) → get → update → list → folder-order round-trip (skipped if that API returns 404) → delete. If **POST `/folders`** returns 404 (common before full production rollout), the test exits early with a warning; use staging or `HACKMD_E2E_FOLDERS=0`.

```bash
HACKMD_E2E_MUTATIONS=1 npm run test:e2e
```

Folder CRUD touches folder display order briefly, then restores the previous order in an `afterAll` hook. To skip folder mutations (e.g. production without `/folders`), set `HACKMD_E2E_FOLDERS=0`.

The read-only `getFolderList` test still treats HTTP 404 as “folders not available on this host yet” and passes without failing the suite.

## License

MIT
1 change: 1 addition & 0 deletions nodejs/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const customJestConfig: JestConfigWithTsJest = {
transformIgnorePatterns: ["<rootDir>/node_modules/"],
extensionsToTreatAsEsm: [".ts"],
setupFiles: ["dotenv/config"],
testPathIgnorePatterns: ["/node_modules/", "<rootDir>/tests/e2e/"],
}

export default customJestConfig
14 changes: 14 additions & 0 deletions nodejs/jest.e2e.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { JestConfigWithTsJest } from "ts-jest"

/** Live API tests; run with `npm run test:e2e` (see nodejs/README.md). */
const e2eJestConfig: JestConfigWithTsJest = {
preset: "ts-jest",
testEnvironment: "node",
transformIgnorePatterns: ["<rootDir>/node_modules/"],
extensionsToTreatAsEsm: [".ts"],
setupFiles: ["dotenv/config"],
testMatch: ["<rootDir>/tests/e2e/**/*.spec.ts"],
testTimeout: 60_000,
}

export default e2eJestConfig
3 changes: 2 additions & 1 deletion nodejs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"watch": "npm run clean && rollup -c -w",
"prepublishOnly": "npm run build",
"lint": "eslint src --fix --ext .ts",
"test": "jest"
"test": "jest",
"test:e2e": "jest --config jest.e2e.config.ts"
},
"keywords": [
"HackMD",
Expand Down
104 changes: 99 additions & 5 deletions nodejs/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,32 @@
import axios, { AxiosInstance, AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { CreateNoteOptions, GetMe, GetUserHistory, GetUserNotes, GetUserNote, CreateUserNote, GetUserTeams, GetTeamNotes, CreateTeamNote, SingleNote, UpdateNoteOptions } from './type'
import {
CreateNoteOptions,
CreateTeamFolderBody,
CreateUserFolderBody,
GetMe,
GetUserHistory,
GetUserNotes,
GetUserNote,
CreateUserNote,
GetUserTeams,
GetTeamNotes,
CreateTeamNote,
SingleNote,
UpdateNoteOptions,
GetFolders,
GetFolder,
GetFolderOrder,
CreateFolderResult,
UpdateFolderResult,
GetTeamFolders,
GetTeamFolder,
GetTeamFolderOrder,
CreateTeamFolderResult,
UpdateTeamFolderResult,
UpdateFolderOrderBody,
UpdateTeamFolderBody,
UpdateUserFolderBody,
} from './type'
import * as HackMDErrors from './error'

export type RequestOptions = {
Expand Down Expand Up @@ -59,6 +86,10 @@ export class API {
}
)

if (options.retryConfig) {
this.createRetryInterceptor(this.axios, options.retryConfig.maxRetries, options.retryConfig.baseDelay)
}

if (options.wrapResponseErrors) {
this.axios.interceptors.response.use(
(response: AxiosResponse) => {
Expand Down Expand Up @@ -94,16 +125,21 @@ 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 {
private isRetryableMethod (method?: string): boolean {
if (!method) return false
const normalized = method.toLowerCase()
return ['get', 'head', 'options', 'put', 'delete'].includes(normalized)
}

private isRetryableError (error: unknown): boolean {
if (!axios.isAxiosError(error)) return false
if (!this.isRetryableMethod(error.config?.method)) return false
return (
!error.response ||
(error.response.status >= 500 && error.response.status < 600) ||
Expand Down Expand Up @@ -197,6 +233,62 @@ export class API {
return this.axios.delete<AxiosResponse>(`teams/${teamPath}/notes/${noteId}`)
}

async getFolderList<Opt extends RequestOptions> (options = defaultOption as Opt): Promise<OptionReturnType<Opt, GetFolders>> {
return this.unwrapData(this.axios.get<GetFolders>('folders'), options.unwrapData) as unknown as OptionReturnType<Opt, GetFolders>
}

async createFolder<Opt extends RequestOptions> (payload: CreateUserFolderBody, options = defaultOption as Opt): Promise<OptionReturnType<Opt, CreateFolderResult>> {
return this.unwrapData(this.axios.post<CreateFolderResult>('folders', payload), options.unwrapData) as unknown as OptionReturnType<Opt, CreateFolderResult>
}

async getFolder<Opt extends RequestOptions> (folderId: string, options = defaultOption as Opt): Promise<OptionReturnType<Opt, GetFolder>> {
return this.unwrapData(this.axios.get<GetFolder>(`folders/${folderId}`), options.unwrapData) as unknown as OptionReturnType<Opt, GetFolder>
}

async updateFolder<Opt extends RequestOptions> (folderId: string, payload: UpdateUserFolderBody, options = defaultOption as Opt): Promise<OptionReturnType<Opt, UpdateFolderResult>> {
return this.unwrapData(this.axios.patch<UpdateFolderResult>(`folders/${folderId}`, payload), options.unwrapData) as unknown as OptionReturnType<Opt, UpdateFolderResult>
}

async deleteFolder<Opt extends RequestOptions> (folderId: string, options = defaultOption as Opt): Promise<OptionReturnType<Opt, void>> {
return this.unwrapData(this.axios.delete(`folders/${folderId}`), options.unwrapData) as unknown as OptionReturnType<Opt, void>
}

async getFolderOrder<Opt extends RequestOptions> (options = defaultOption as Opt): Promise<OptionReturnType<Opt, GetFolderOrder>> {
return this.unwrapData(this.axios.get<GetFolderOrder>('folders/folder-order'), options.unwrapData) as unknown as OptionReturnType<Opt, GetFolderOrder>
}

async updateFolderOrder<Opt extends RequestOptions> (payload: UpdateFolderOrderBody, options = defaultOption as Opt): Promise<OptionReturnType<Opt, UpdateFolderResult>> {
return this.unwrapData(this.axios.put<UpdateFolderResult>('folders/folder-order', payload), options.unwrapData) as unknown as OptionReturnType<Opt, UpdateFolderResult>
}

async getTeamFolderList<Opt extends RequestOptions> (teamPath: string, options = defaultOption as Opt): Promise<OptionReturnType<Opt, GetTeamFolders>> {
return this.unwrapData(this.axios.get<GetTeamFolders>(`teams/${teamPath}/folders`), options.unwrapData) as unknown as OptionReturnType<Opt, GetTeamFolders>
}

async createTeamFolder<Opt extends RequestOptions> (teamPath: string, payload: CreateTeamFolderBody, options = defaultOption as Opt): Promise<OptionReturnType<Opt, CreateTeamFolderResult>> {
return this.unwrapData(this.axios.post<CreateTeamFolderResult>(`teams/${teamPath}/folders`, payload), options.unwrapData) as unknown as OptionReturnType<Opt, CreateTeamFolderResult>
}

async getTeamFolder<Opt extends RequestOptions> (teamPath: string, folderId: string, options = defaultOption as Opt): Promise<OptionReturnType<Opt, GetTeamFolder>> {
return this.unwrapData(this.axios.get<GetTeamFolder>(`teams/${teamPath}/folders/${folderId}`), options.unwrapData) as unknown as OptionReturnType<Opt, GetTeamFolder>
}

async updateTeamFolder<Opt extends RequestOptions> (teamPath: string, folderId: string, payload: UpdateTeamFolderBody, options = defaultOption as Opt): Promise<OptionReturnType<Opt, UpdateTeamFolderResult>> {
return this.unwrapData(this.axios.patch<UpdateTeamFolderResult>(`teams/${teamPath}/folders/${folderId}`, payload), options.unwrapData) as unknown as OptionReturnType<Opt, UpdateTeamFolderResult>
}

async deleteTeamFolder<Opt extends RequestOptions> (teamPath: string, folderId: string, options = defaultOption as Opt): Promise<OptionReturnType<Opt, void>> {
return this.unwrapData(this.axios.delete(`teams/${teamPath}/folders/${folderId}`), options.unwrapData) as unknown as OptionReturnType<Opt, void>
}

async getTeamFolderOrder<Opt extends RequestOptions> (teamPath: string, options = defaultOption as Opt): Promise<OptionReturnType<Opt, GetTeamFolderOrder>> {
return this.unwrapData(this.axios.get<GetTeamFolderOrder>(`teams/${teamPath}/folders/folder-order`), options.unwrapData) as unknown as OptionReturnType<Opt, GetTeamFolderOrder>
}

async updateTeamFolderOrder<Opt extends RequestOptions> (teamPath: string, payload: UpdateFolderOrderBody, options = defaultOption as Opt): Promise<OptionReturnType<Opt, UpdateTeamFolderResult>> {
return this.unwrapData(this.axios.put<UpdateTeamFolderResult>(`teams/${teamPath}/folders/folder-order`, payload), options.unwrapData) as unknown as OptionReturnType<Opt, UpdateTeamFolderResult>
}

private unwrapData<T> (reqP: Promise<AxiosResponse<T>>, unwrap = true, includeEtag = false) {
if (!unwrap) {
// For raw responses, etag is available via response.headers
Expand All @@ -211,4 +303,6 @@ export class API {
}
}

export * from './type'

export default API
70 changes: 69 additions & 1 deletion nodejs/src/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,13 @@ export enum CommentPermissionType {
export type CreateNoteOptions = {
title?: string
content?: string
description?: string
tags?: string[]
readPermission?: NotePermissionRole,
writePermission?: NotePermissionRole,
commentPermission?: CommentPermissionType,
permalink?: string
parentFolderId?: string
}

export type Team = {
Expand Down Expand Up @@ -62,6 +65,16 @@ export enum NotePermissionRole {
GUEST = 'guest'
}

/** Folder breadcrumb segment as returned on notes (OpenAPI `FolderPath`). */
export type FolderPath = {
id: string
name: string
icon: string | null
color: string | null
parentId: string | null
clientId: string
}

export type Note = {
id: string
title: string
Expand All @@ -79,13 +92,16 @@ export type Note = {

readPermission: NotePermissionRole
writePermission: NotePermissionRole
folderPaths?: FolderPath[]
}

export type SingleNote = Note & {
content: string
}

export type UpdateNoteOptions = Partial<Pick<SingleNote, 'content' | 'title' | 'tags' | 'readPermission' | 'writePermission' | 'permalink'>>
export type UpdateNoteOptions = Partial<Pick<SingleNote, 'content' | 'title' | 'tags' | 'readPermission' | 'writePermission' | 'permalink'>> & {
parentFolderId?: string
}

// User
export type GetMe = User
Expand All @@ -107,3 +123,55 @@ export type CreateTeamNote = SingleNote
export type UpdateTeamNote = void
export type DeleteTeamNote = void

// Folders (user & team workspaces)
export type ApiFolder = {
id: string
name: string
description: string | null
icon: string | null
color: string | null
parentFolderId: string | null
createdAt: number
updatedAt: number
}

/** Maps each parent folder id or the literal `root` to ordered child folder ids. */
export type ApiFolderOrder = Record<string, string[]>

export type CreateUserFolderBody = {
name?: string
description?: string
icon?: string
color?: string
parentFolderId?: string
}

export type UpdateUserFolderBody = {
name?: string
description?: string | null
icon?: string | null
color?: string | null
parentFolderId?: string | null
}

export type CreateTeamFolderBody = CreateUserFolderBody

export type UpdateTeamFolderBody = UpdateUserFolderBody

export type UpdateFolderOrderBody = {
order: ApiFolderOrder
}

export type GetFolders = ApiFolder[]
export type GetTeamFolders = ApiFolder[]
export type GetFolder = ApiFolder
export type GetTeamFolder = ApiFolder
export type CreateFolderResult = ApiFolder
export type CreateTeamFolderResult = ApiFolder
export type UpdateFolderResult = ApiFolder
export type UpdateTeamFolderResult = ApiFolder
export type DeleteFolderResult = void
export type DeleteTeamFolderResult = void
export type GetFolderOrder = ApiFolderOrder
export type GetTeamFolderOrder = ApiFolderOrder

Loading
Loading