├── .github └── workflows │ ├── ci.yml │ ├── pre-release.yml │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── examples └── nodejs │ ├── .env.example │ ├── README.md │ ├── index.js │ ├── package-lock.json │ └── package.json └── nodejs ├── .eslintrc.cjs ├── .gitignore ├── .nvmrc ├── README.md ├── jest.config.ts ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── error.ts ├── index.ts └── type.ts ├── tests ├── api.spec.ts ├── etag.spec.ts └── mock │ ├── handlers.ts │ └── index.ts ├── tsconfig.base.json ├── tsconfig.build.json └── tsconfig.json /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ master, develop ] 6 | pull_request: 7 | branches: [ master, develop ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Node.js 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: '20' 20 | cache: 'npm' 21 | cache-dependency-path: nodejs/package-lock.json 22 | 23 | - name: Install dependencies 24 | working-directory: nodejs 25 | run: npm ci 26 | 27 | - name: Run lint 28 | working-directory: nodejs 29 | run: npm run lint 30 | 31 | - name: Build 32 | working-directory: nodejs 33 | run: npm run build 34 | 35 | - name: Run tests 36 | working-directory: nodejs 37 | env: 38 | HACKMD_ACCESS_TOKEN: "test_token_123456789" 39 | run: npm test -------------------------------------------------------------------------------- /.github/workflows/pre-release.yml: -------------------------------------------------------------------------------- 1 | name: Pre-release to NPM 2 | 3 | on: 4 | push: 5 | branches: 6 | - develop 7 | 8 | jobs: 9 | pre-release: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | 17 | - name: Set up Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: '20' 21 | registry-url: 'https://registry.npmjs.org' 22 | cache: 'npm' 23 | cache-dependency-path: nodejs/package-lock.json 24 | 25 | - name: Install dependencies 26 | working-directory: nodejs 27 | run: npm ci 28 | 29 | - name: Configure Git 30 | run: | 31 | git config --global user.name "github-actions[bot]" 32 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 33 | 34 | - name: Generate pre-release version 35 | working-directory: nodejs 36 | run: | 37 | # Get current version from package.json 38 | CURRENT_VERSION=$(node -p "require('./package.json').version") 39 | 40 | # Get short commit hash 41 | SHORT_SHA=$(git rev-parse --short HEAD) 42 | 43 | # Get current timestamp 44 | TIMESTAMP=$(date +%Y%m%d%H%M%S) 45 | 46 | # Create pre-release version: current-version-beta.timestamp.sha 47 | PRE_RELEASE_VERSION="${CURRENT_VERSION}-beta.${TIMESTAMP}.${SHORT_SHA}" 48 | 49 | echo "Pre-release version: $PRE_RELEASE_VERSION" 50 | echo "PRE_RELEASE_VERSION=$PRE_RELEASE_VERSION" >> $GITHUB_ENV 51 | 52 | # Update package.json with pre-release version 53 | npm version $PRE_RELEASE_VERSION --no-git-tag-version 54 | 55 | - name: Build 56 | working-directory: nodejs 57 | run: npm run build 58 | 59 | - name: Publish pre-release to NPM 60 | working-directory: nodejs 61 | run: npm publish --tag beta --access public 62 | env: 63 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 64 | 65 | - name: Create GitHub pre-release 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | run: | 69 | gh release create "v${{ env.PRE_RELEASE_VERSION }}" \ 70 | --title "Pre-release v${{ env.PRE_RELEASE_VERSION }}" \ 71 | --notes "🚀 **Pre-release from develop branch** 72 | 73 | This is an automated pre-release build from the develop branch. 74 | 75 | **Changes:** 76 | - Commit: ${{ github.sha }} 77 | - Branch: ${{ github.ref_name }} 78 | 79 | **Installation:** 80 | \`\`\`bash 81 | npm install @hackmd/api@beta 82 | \`\`\` 83 | 84 | **Note:** This is a pre-release version and may contain unstable features." \ 85 | --prerelease -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Set up Node.js 15 | uses: actions/setup-node@v4 16 | with: 17 | node-version: '20' 18 | registry-url: 'https://registry.npmjs.org' 19 | cache: 'npm' 20 | cache-dependency-path: nodejs/package-lock.json 21 | 22 | - name: Install dependencies 23 | working-directory: nodejs 24 | run: npm ci 25 | 26 | - name: Build 27 | working-directory: nodejs 28 | run: npm run build 29 | 30 | - name: Publish to NPM 31 | working-directory: nodejs 32 | run: npm publish --access public 33 | env: 34 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 35 | 36 | - name: Extract version from tag 37 | run: | 38 | # Extract version from tag (remove 'v' prefix) 39 | VERSION=${GITHUB_REF#refs/tags/v} 40 | echo "VERSION=$VERSION" >> $GITHUB_ENV 41 | echo "Extracted version: $VERSION" 42 | 43 | - name: Create draft release 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | run: | 47 | gh release create "$GITHUB_REF_NAME" \ 48 | --title "Release v${{ env.VERSION }}" \ 49 | --draft -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Environment variables 2 | .env 3 | .env.* 4 | !.env.example 5 | node_modules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2022 HackMD 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HackMD API Clients 2 | 3 | This repository contains a set of packages for interacting with the [HackMD API](https://hackmd.io/). 4 | 5 | ## Node.JS 6 | 7 | See [README](./nodejs) 8 | 9 | ## Examples 10 | 11 | To help you get started quickly, we provide comprehensive usage examples in the `examples/` directory: 12 | 13 | ### Node.js Example 14 | 15 | The `examples/nodejs/` directory contains a complete example project demonstrating: 16 | 17 | - User information retrieval 18 | - Note creation and management 19 | - ETag support for caching 20 | - Content updates 21 | - Error handling with retry logic 22 | - Environment variable configuration 23 | 24 | To run the Node.js example: 25 | 26 | 1. Navigate to the example directory: `cd examples/nodejs` 27 | 2. Follow the setup instructions in [examples/nodejs/README.md](./examples/nodejs/README.md) 28 | 3. Set your HackMD access token 29 | 4. Run `npm start` 30 | 31 | The example includes detailed comments and demonstrates best practices for using the HackMD API client. 32 | 33 | ## LICENSE 34 | 35 | MIT 36 | -------------------------------------------------------------------------------- /examples/nodejs/.env.example: -------------------------------------------------------------------------------- 1 | HACKMD_ACCESS_TOKEN=YOUR_ACCESS_TOKEN -------------------------------------------------------------------------------- /examples/nodejs/README.md: -------------------------------------------------------------------------------- 1 | # HackMD API Client Example 2 | 3 | This is an example project demonstrating the usage of the HackMD API client. 4 | 5 | ## Setup 6 | 7 | 1. First, build the HackMD API package: 8 | ```bash 9 | cd ../../nodejs 10 | npm install 11 | npm run build 12 | cd ../examples/nodejs 13 | ``` 14 | 15 | 2. Install the example dependencies: 16 | ```bash 17 | npm install 18 | ``` 19 | 20 | 3. Set up your HackMD access token using one of these methods: 21 | 22 | a. Set it as an environment variable: 23 | ```bash 24 | # For Unix/Linux/macOS 25 | export HACKMD_ACCESS_TOKEN=your_access_token_here 26 | 27 | # For Windows PowerShell 28 | $env:HACKMD_ACCESS_TOKEN="your_access_token_here" 29 | ``` 30 | 31 | b. Or create a `.env` file in the project root (not tracked by git): 32 | ``` 33 | HACKMD_ACCESS_TOKEN=your_access_token_here 34 | ``` 35 | 36 | You can get your access token from [HackMD API documentation](https://hackmd.io/@hackmd-api/developer-portal). 37 | 38 | ## Running the Example 39 | 40 | To run the example: 41 | 42 | ```bash 43 | npm start 44 | ``` 45 | 46 | ## What's Demonstrated 47 | 48 | The example demonstrates several features of the HackMD API client: 49 | 50 | 1. Getting user information 51 | 2. Creating a new note 52 | 3. Using ETag support for caching 53 | 4. Updating note content 54 | 5. Getting raw response data 55 | 6. Deleting notes 56 | 57 | ## Features Shown 58 | 59 | - Retry configuration with exponential backoff 60 | - ETag support for caching 61 | - Response data unwrapping 62 | - Error handling 63 | - Environment variable configuration -------------------------------------------------------------------------------- /examples/nodejs/index.js: -------------------------------------------------------------------------------- 1 | import HackMDAPI from '@hackmd/api'; 2 | import dotenv from 'dotenv'; 3 | 4 | // Load environment variables 5 | dotenv.config(); 6 | 7 | // Check for required environment variable 8 | if (!process.env.HACKMD_ACCESS_TOKEN) { 9 | console.error('Error: HACKMD_ACCESS_TOKEN environment variable is not set.'); 10 | console.error('Please set your HackMD access token using one of these methods:'); 11 | console.error('1. Create a .env file with HACKMD_ACCESS_TOKEN=your_token_here'); 12 | console.error('2. Set the environment variable directly: export HACKMD_ACCESS_TOKEN=your_token_here'); 13 | process.exit(1); 14 | } 15 | 16 | // Create API client with retry configuration 17 | const client = new HackMDAPI(process.env.HACKMD_ACCESS_TOKEN, 'https://api.hackmd.io/v1', { 18 | retryConfig: { 19 | maxRetries: 3, 20 | baseDelay: 100 21 | } 22 | }); 23 | 24 | async function main() { 25 | try { 26 | // Example 1: Get user information 27 | console.log('Getting user information...'); 28 | const me = await client.getMe(); 29 | console.log('User email:', me.email); 30 | console.log('User name:', me.name); 31 | 32 | // Example 2: Create a new note 33 | console.log('\nCreating a new note...'); 34 | const newNote = await client.createNote({ 35 | title: 'Test Note', 36 | content: '# Hello from HackMD API\n\nThis is a test note created using the API client.', 37 | readPermission: 'guest', 38 | writePermission: 'owner' 39 | }); 40 | console.log('Created note ID:', newNote.id); 41 | console.log('Note URL:', newNote.publishLink); 42 | 43 | // Example 3: Get note with ETag support 44 | console.log('\nGetting note with ETag support...'); 45 | const note = await client.getNote(newNote.id); 46 | console.log('Note content:', note.content); 47 | 48 | // Second request with ETag 49 | const updatedNote = await client.getNote(newNote.id, { etag: note.etag }); 50 | console.log('Note status:', updatedNote.status); 51 | 52 | // Example 4: Update note content 53 | console.log('\nUpdating note content...'); 54 | const updatedContent = await client.updateNoteContent(newNote.id, '# Updated Content\n\nThis note has been updated!'); 55 | console.log('Updated note content:', updatedContent.content); 56 | 57 | // Example 5: Get raw response (unwrapData: false) 58 | console.log('\nGetting raw response...'); 59 | const rawResponse = await client.getNote(newNote.id, { unwrapData: false }); 60 | console.log('Response headers:', rawResponse.headers); 61 | console.log('Response status:', rawResponse.status); 62 | 63 | // Example 6: Delete the test note 64 | console.log('\nCleaning up - deleting test note...'); 65 | await client.deleteNote(newNote.id); 66 | console.log('Note deleted successfully'); 67 | 68 | } catch (error) { 69 | console.error('Error:', error.message); 70 | if (error.response) { 71 | console.error('Response status:', error.response.status); 72 | console.error('Response data:', error.response.data); 73 | } 74 | } 75 | } 76 | 77 | main(); -------------------------------------------------------------------------------- /examples/nodejs/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackmd-api-example", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "hackmd-api-example", 9 | "version": "1.0.0", 10 | "dependencies": { 11 | "@hackmd/api": "file:../../nodejs", 12 | "dotenv": "^16.4.5" 13 | } 14 | }, 15 | "../../nodejs": { 16 | "name": "@hackmd/api", 17 | "version": "2.4.0", 18 | "license": "MIT", 19 | "dependencies": { 20 | "axios": "^1.8.4", 21 | "tslib": "^1.14.1" 22 | }, 23 | "devDependencies": { 24 | "@faker-js/faker": "^7.6.0", 25 | "@types/eslint": "^8.21.0", 26 | "@types/jest": "^29.4.0", 27 | "@types/node": "^13.11.1", 28 | "@typescript-eslint/eslint-plugin": "^5.52.0", 29 | "@typescript-eslint/parser": "^5.52.0", 30 | "dotenv": "^16.0.3", 31 | "eslint": "^8.9.0", 32 | "jest": "^29.4.2", 33 | "msw": "^2.7.3", 34 | "rimraf": "^4.1.2", 35 | "ts-jest": "^29.0.5", 36 | "ts-node": "^8.8.2", 37 | "typescript": "^4.9.5" 38 | } 39 | }, 40 | "node_modules/@hackmd/api": { 41 | "resolved": "../../nodejs", 42 | "link": true 43 | }, 44 | "node_modules/dotenv": { 45 | "version": "16.5.0", 46 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", 47 | "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", 48 | "license": "BSD-2-Clause", 49 | "engines": { 50 | "node": ">=12" 51 | }, 52 | "funding": { 53 | "url": "https://dotenvx.com" 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /examples/nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackmd-api-example", 3 | "version": "1.0.0", 4 | "description": "Example usage of HackMD API client", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "node index.js" 9 | }, 10 | "dependencies": { 11 | "@hackmd/api": "file:../../nodejs", 12 | "dotenv": "^16.4.5" 13 | } 14 | } -------------------------------------------------------------------------------- /nodejs/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | /** @type {import('eslint').Linter.Config} */ 4 | const config = { 5 | "parser": "@typescript-eslint/parser", 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "rules": { 11 | "no-trailing-spaces": ["warn", { "skipBlankLines": false }], 12 | "semi": ["warn", "never"], 13 | "@typescript-eslint/no-non-null-assertion": "off", 14 | "keyword-spacing": ["warn", {"before": true, "after": true}], 15 | "space-infix-ops": "warn", 16 | "space-before-function-paren": "warn", 17 | "eol-last": ["error", "always"] 18 | }, 19 | "parserOptions": { 20 | "project": [ 21 | path.resolve(__dirname, "tsconfig.json"), 22 | ] 23 | }, 24 | "ignorePatterns": [ 25 | ".eslintrc.cjs" 26 | ], 27 | } 28 | 29 | module.exports = config -------------------------------------------------------------------------------- /nodejs/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | 82 | # Nuxt.js build / generate output 83 | .nuxt 84 | dist 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and not Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | 107 | # Stores VSCode versions used for testing VSCode extensions 108 | .vscode-test 109 | 110 | # yarn v2 111 | 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .pnp.* 116 | -------------------------------------------------------------------------------- /nodejs/.nvmrc: -------------------------------------------------------------------------------- 1 | lts/hydrogen 2 | -------------------------------------------------------------------------------- /nodejs/README.md: -------------------------------------------------------------------------------- 1 | # HackMD Node.js API Client 2 | 3 | ![npm](https://img.shields.io/npm/v/@hackmd/api) 4 | 5 | ## About 6 | 7 | This is a Node.js client for the [HackMD API](https://hackmd.io/). 8 | 9 | You can sign up for an account at [hackmd.io](https://hackmd.io/), and then create access tokens for your projects by following the [HackMD API documentation](https://hackmd.io/@hackmd-api/developer-portal). 10 | 11 | For bugs and feature requests, please open an issue or pull request on [GitHub](https://github.com/hackmdio/api-client). 12 | 13 | ## **v2.0.0 Update Note** 14 | 15 | `v2.0.0` is a completely rewrite and is incompatible with `v1.x.x`. But the best of all, it does not require Node.JS runtime anymore, which means you can use it in a browser. We recommend you to upgrade to `v2.0.0` if you are using the old one. 16 | 17 | ## Installation 18 | 19 | ```bash 20 | npm install @hackmd/api --save 21 | ``` 22 | 23 | ## Example 24 | 25 | ### ES Modules (ESM) 26 | 27 | ```javascript 28 | // Default import 29 | import HackMDAPI from '@hackmd/api' 30 | 31 | // Or named import 32 | import { API } from '@hackmd/api' 33 | 34 | const client = new HackMDAPI('YOUR_ACCESS_TOKEN' /* required */, 'https://api.hackmd.io/v1' /* optional */) 35 | 36 | client.getMe().then(me => { 37 | console.log(me.email) 38 | }) 39 | ``` 40 | 41 | ### CommonJS 42 | 43 | ```javascript 44 | // Default import 45 | const HackMDAPI = require('@hackmd/api').default 46 | 47 | // Or named import 48 | const { API } = require('@hackmd/api') 49 | 50 | const client = new HackMDAPI('YOUR_ACCESS_TOKEN', 'https://api.hackmd.io/v1') 51 | 52 | client.getMe().then(me => { 53 | console.log(me.email) 54 | }) 55 | ``` 56 | 57 | ### Legacy Import Support 58 | 59 | For backward compatibility, the package also supports legacy import paths: 60 | 61 | ```javascript 62 | // ESM 63 | import HackMDAPI from '@hackmd/api/dist' 64 | import { API } from '@hackmd/api/dist' 65 | 66 | // CommonJS 67 | const HackMDAPI = require('@hackmd/api/dist').default 68 | const { API } = require('@hackmd/api/dist') 69 | 70 | // Direct file imports 71 | import { API } from '@hackmd/api/dist/index.js' 72 | ``` 73 | 74 | ## Advanced Features 75 | 76 | ### Retry Configuration 77 | 78 | The client supports automatic retry for failed requests with exponential backoff. You can configure retry behavior when creating the client: 79 | 80 | ```javascript 81 | const client = new HackMDAPI('YOUR_ACCESS_TOKEN', 'https://api.hackmd.io/v1', { 82 | retryConfig: { 83 | maxRetries: 3, // Maximum number of retry attempts 84 | baseDelay: 100 // Base delay in milliseconds for exponential backoff 85 | } 86 | }) 87 | ``` 88 | 89 | The client will automatically retry requests that fail with: 90 | - 5xx server errors 91 | - 429 Too Many Requests errors 92 | - Network errors 93 | 94 | ### Response Data Handling 95 | 96 | By default, the client automatically unwraps the response data from the Axios response object. You can control this behavior using the `unwrapData` option: 97 | 98 | ```javascript 99 | // Get raw Axios response (includes headers, status, etc.) 100 | const response = await client.getMe({ unwrapData: false }) 101 | 102 | // Get only the data (default behavior) 103 | const data = await client.getMe({ unwrapData: true }) 104 | ``` 105 | 106 | ### ETag Support 107 | 108 | The client supports ETag-based caching for note retrieval. You can pass an ETag to check if the content has changed: 109 | 110 | ```javascript 111 | // First request 112 | const note = await client.getNote('note-id') 113 | const etag = note.etag 114 | 115 | // Subsequent request with ETag 116 | const updatedNote = await client.getNote('note-id', { etag }) 117 | // If the note hasn't changed, the response will have status 304 118 | ``` 119 | 120 | ## API 121 | 122 | 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. 123 | 124 | ## License 125 | 126 | MIT 127 | -------------------------------------------------------------------------------- /nodejs/jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { JestConfigWithTsJest } from "ts-jest" 2 | 3 | const customJestConfig: JestConfigWithTsJest = { 4 | preset: "ts-jest", 5 | testEnvironment: "node", 6 | transformIgnorePatterns: ["/node_modules/"], 7 | extensionsToTreatAsEsm: [".ts"], 8 | setupFiles: ["dotenv/config"], 9 | } 10 | 11 | export default customJestConfig 12 | -------------------------------------------------------------------------------- /nodejs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hackmd/api", 3 | "version": "2.4.0", 4 | "description": "HackMD Node.js API Client", 5 | "type": "module", 6 | "main": "./dist/index.js", 7 | "module": "./dist/index.js", 8 | "types": "./dist/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "import": "./dist/index.js", 12 | "require": "./dist/index.cjs", 13 | "types": "./dist/index.d.ts" 14 | }, 15 | "./dist": { 16 | "import": "./dist/index.js", 17 | "require": "./dist/index.cjs", 18 | "types": "./dist/index.d.ts" 19 | }, 20 | "./dist/*": "./dist/*" 21 | }, 22 | "scripts": { 23 | "clean": "rimraf dist", 24 | "build": "npm run clean && rollup -c", 25 | "watch": "npm run clean && rollup -c -w", 26 | "prepublishOnly": "npm run build", 27 | "lint": "eslint src --fix --ext .ts", 28 | "test": "jest" 29 | }, 30 | "keywords": [ 31 | "HackMD", 32 | "API Client" 33 | ], 34 | "files": [ 35 | "dist/*", 36 | "README.md", 37 | "LICENSE" 38 | ], 39 | "author": { 40 | "name": "HackMD", 41 | "email": "support@hackmd.io", 42 | "url": "https://hackmd.io" 43 | }, 44 | "license": "MIT", 45 | "devDependencies": { 46 | "@faker-js/faker": "^7.6.0", 47 | "@rollup/plugin-commonjs": "^28.0.3", 48 | "@rollup/plugin-node-resolve": "^16.0.1", 49 | "@rollup/plugin-typescript": "^12.1.2", 50 | "@types/eslint": "^8.21.0", 51 | "@types/jest": "^29.4.0", 52 | "@types/node": "^13.11.1", 53 | "@typescript-eslint/eslint-plugin": "^6.21.0", 54 | "@typescript-eslint/parser": "^6.21.0", 55 | "dotenv": "^16.0.3", 56 | "eslint": "^8.57.1", 57 | "jest": "^29.4.2", 58 | "msw": "^2.7.3", 59 | "rimraf": "^4.1.2", 60 | "rollup": "^4.41.1", 61 | "ts-jest": "^29.0.5", 62 | "ts-node": "^8.8.2", 63 | "typescript": "^4.9.5" 64 | }, 65 | "dependencies": { 66 | "axios": "^1.8.4", 67 | "tslib": "^1.14.1" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /nodejs/rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve' 2 | import commonjs from '@rollup/plugin-commonjs' 3 | import typescript from '@rollup/plugin-typescript' 4 | 5 | export default [ 6 | // ESM build 7 | { 8 | input: 'src/index.ts', 9 | output: { 10 | file: 'dist/index.js', 11 | format: 'esm', 12 | sourcemap: true, 13 | }, 14 | plugins: [ 15 | resolve({ 16 | extensions: ['.ts', '.js'] 17 | }), 18 | commonjs(), 19 | typescript({ 20 | tsconfig: './tsconfig.build.json', 21 | declaration: true, 22 | declarationDir: './dist', 23 | rootDir: './src' 24 | }) 25 | ], 26 | external: ['axios'] 27 | }, 28 | // CJS build 29 | { 30 | input: 'src/index.ts', 31 | output: { 32 | file: 'dist/index.cjs', 33 | format: 'cjs', 34 | sourcemap: true, 35 | exports: 'named' 36 | }, 37 | plugins: [ 38 | resolve({ 39 | extensions: ['.ts', '.js'] 40 | }), 41 | commonjs(), 42 | typescript({ 43 | tsconfig: './tsconfig.build.json', 44 | declaration: false // Only generate declarations once 45 | }) 46 | ], 47 | external: ['axios'] 48 | } 49 | ] 50 | -------------------------------------------------------------------------------- /nodejs/src/error.ts: -------------------------------------------------------------------------------- 1 | export class HackMDError extends Error { 2 | constructor (message: string) { 3 | super(message) 4 | Object.setPrototypeOf(this, new.target.prototype) 5 | } 6 | } 7 | 8 | export class HttpResponseError extends HackMDError { 9 | public constructor ( 10 | message: string, 11 | readonly code: number, 12 | readonly statusText: string, 13 | ) { 14 | super(message) 15 | } 16 | } 17 | 18 | export class MissingRequiredArgument extends HackMDError {} 19 | export class InternalServerError extends HttpResponseError {} 20 | export class TooManyRequestsError extends HttpResponseError { 21 | public constructor ( 22 | message: string, 23 | readonly code: number, 24 | readonly statusText: string, 25 | readonly userLimit: number, 26 | readonly userRemaining: number, 27 | readonly resetAfter?: number, 28 | ) { 29 | super(message, code, statusText) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /nodejs/src/index.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance, AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios' 2 | import { CreateNoteOptions, GetMe, GetUserHistory, GetUserNotes, GetUserNote, CreateUserNote, GetUserTeams, GetTeamNotes, CreateTeamNote, SingleNote } from './type' 3 | import * as HackMDErrors from './error' 4 | 5 | export type RequestOptions = { 6 | unwrapData?: boolean; 7 | etag?: string | undefined; 8 | } 9 | 10 | const defaultOption: RequestOptions = { 11 | unwrapData: true, 12 | } 13 | 14 | type OptionReturnType = Opt extends { unwrapData: false } ? AxiosResponse : Opt extends { unwrapData: true } ? T : T 15 | 16 | export type APIClientOptions = { 17 | wrapResponseErrors: boolean; 18 | timeout?: number; 19 | retryConfig?: { 20 | maxRetries: number; 21 | baseDelay: number; 22 | }; 23 | } 24 | 25 | export class API { 26 | private axios: AxiosInstance 27 | 28 | constructor ( 29 | readonly accessToken: string, 30 | public hackmdAPIEndpointURL: string = "https://api.hackmd.io/v1", 31 | public options: APIClientOptions = { 32 | wrapResponseErrors: true, 33 | timeout: 30000, 34 | retryConfig: { 35 | maxRetries: 3, 36 | baseDelay: 100, 37 | }, 38 | } 39 | ) { 40 | if (!accessToken) { 41 | throw new HackMDErrors.MissingRequiredArgument('Missing access token when creating HackMD client') 42 | } 43 | 44 | this.axios = axios.create({ 45 | baseURL: hackmdAPIEndpointURL, 46 | headers:{ 47 | "Content-Type": "application/json", 48 | }, 49 | timeout: options.timeout 50 | }) 51 | 52 | this.axios.interceptors.request.use( 53 | (config: InternalAxiosRequestConfig) => { 54 | config.headers.set('Authorization', `Bearer ${accessToken}`) 55 | return config 56 | }, 57 | (err: AxiosError) => { 58 | return Promise.reject(err) 59 | } 60 | ) 61 | 62 | if (options.wrapResponseErrors) { 63 | this.axios.interceptors.response.use( 64 | (response: AxiosResponse) => { 65 | return response 66 | }, 67 | async (err: AxiosError) => { 68 | if (!err.response) { 69 | return Promise.reject(err) 70 | } 71 | 72 | if (err.response.status >= 500) { 73 | throw new HackMDErrors.InternalServerError( 74 | `HackMD internal error (${err.response.status} ${err.response.statusText})`, 75 | err.response.status, 76 | err.response.statusText, 77 | ) 78 | } else if (err.response.status === 429) { 79 | throw new HackMDErrors.TooManyRequestsError( 80 | `Too many requests (${err.response.status} ${err.response.statusText})`, 81 | err.response.status, 82 | err.response.statusText, 83 | parseInt(err.response.headers['x-ratelimit-userlimit'], 10), 84 | parseInt(err.response.headers['x-ratelimit-userremaining'], 10), 85 | parseInt(err.response.headers['x-ratelimit-userreset'], 10), 86 | ) 87 | } else { 88 | throw new HackMDErrors.HttpResponseError( 89 | `Received an error response (${err.response.status} ${err.response.statusText}) from HackMD`, 90 | err.response.status, 91 | err.response.statusText, 92 | ) 93 | } 94 | } 95 | ) 96 | } 97 | if (options.retryConfig) { 98 | this.createRetryInterceptor(this.axios, options.retryConfig.maxRetries, options.retryConfig.baseDelay) 99 | } 100 | } 101 | 102 | private exponentialBackoff (retries: number, baseDelay: number): number { 103 | return Math.pow(2, retries) * baseDelay 104 | } 105 | 106 | private isRetryableError (error: AxiosError): boolean { 107 | return ( 108 | !error.response || 109 | (error.response.status >= 500 && error.response.status < 600) || 110 | error.response.status === 429 111 | ) 112 | } 113 | 114 | private createRetryInterceptor (axiosInstance: AxiosInstance, maxRetries: number, baseDelay: number): void { 115 | let retryCount = 0 116 | 117 | axiosInstance.interceptors.response.use( 118 | response => response, 119 | async error => { 120 | if (retryCount < maxRetries && this.isRetryableError(error)) { 121 | const remainingCredits = parseInt(error.response?.headers['x-ratelimit-userremaining'], 10) 122 | 123 | if (isNaN(remainingCredits) || remainingCredits > 0) { 124 | retryCount++ 125 | const delay = this.exponentialBackoff(retryCount, baseDelay) 126 | console.warn(`Retrying request... attempt #${retryCount} after delay of ${delay}ms`) 127 | await new Promise(resolve => setTimeout(resolve, delay)) 128 | return axiosInstance(error.config) 129 | } 130 | } 131 | 132 | retryCount = 0 // Reset retry count after a successful request or when not retrying 133 | return Promise.reject(error) 134 | } 135 | ) 136 | } 137 | async getMe (options = defaultOption as Opt): Promise> { 138 | return this.unwrapData(this.axios.get("me"), options.unwrapData) as unknown as OptionReturnType 139 | } 140 | 141 | async getHistory (options = defaultOption as Opt): Promise> { 142 | return this.unwrapData(this.axios.get("history"), options.unwrapData) as unknown as OptionReturnType 143 | } 144 | 145 | async getNoteList (options = defaultOption as Opt): Promise> { 146 | return this.unwrapData(this.axios.get("notes"), options.unwrapData) as unknown as OptionReturnType 147 | } 148 | 149 | async getNote (noteId: string, options = defaultOption as Opt): Promise> { 150 | // Prepare request config with etag if provided in options 151 | const config = options.etag ? { 152 | headers: { 'If-None-Match': options.etag }, 153 | // Consider 304 responses as successful 154 | validateStatus: (status: number) => (status >= 200 && status < 300) || status === 304 155 | } : undefined 156 | const request = this.axios.get(`notes/${noteId}`, config) 157 | return this.unwrapData(request, options.unwrapData, true) as unknown as OptionReturnType 158 | } 159 | 160 | async createNote (payload: CreateNoteOptions, options = defaultOption as Opt): Promise> { 161 | return this.unwrapData(this.axios.post("notes", payload), options.unwrapData, true) as unknown as OptionReturnType 162 | } 163 | 164 | async updateNoteContent (noteId: string, content?: string, options = defaultOption as Opt): Promise> { 165 | return this.unwrapData(this.axios.patch(`notes/${noteId}`, { content }), options.unwrapData, true) as unknown as OptionReturnType 166 | } 167 | 168 | async updateNote (noteId: string, payload: Partial>, options = defaultOption as Opt): Promise> { 169 | return this.unwrapData(this.axios.patch(`notes/${noteId}`, payload), options.unwrapData, true) as unknown as OptionReturnType 170 | } 171 | 172 | async deleteNote (noteId: string, options = defaultOption as Opt): Promise> { 173 | return this.unwrapData(this.axios.delete(`notes/${noteId}`), options.unwrapData) as unknown as OptionReturnType 174 | } 175 | 176 | async getTeams (options = defaultOption as Opt): Promise> { 177 | return this.unwrapData(this.axios.get("teams"), options.unwrapData) as unknown as OptionReturnType 178 | } 179 | 180 | async getTeamNotes (teamPath: string, options = defaultOption as Opt): Promise> { 181 | return this.unwrapData(this.axios.get(`teams/${teamPath}/notes`), options.unwrapData) as unknown as OptionReturnType 182 | } 183 | 184 | async createTeamNote (teamPath: string, payload: CreateNoteOptions, options = defaultOption as Opt): Promise> { 185 | return this.unwrapData(this.axios.post(`teams/${teamPath}/notes`, payload), options.unwrapData) as unknown as OptionReturnType 186 | } 187 | 188 | async updateTeamNoteContent (teamPath: string, noteId: string, content?: string): Promise { 189 | return this.axios.patch(`teams/${teamPath}/notes/${noteId}`, { content }) 190 | } 191 | 192 | async updateTeamNote (teamPath: string, noteId: string, options: Partial>): Promise { 193 | return this.axios.patch(`teams/${teamPath}/notes/${noteId}`, options) 194 | } 195 | 196 | async deleteTeamNote (teamPath: string, noteId: string): Promise { 197 | return this.axios.delete(`teams/${teamPath}/notes/${noteId}`) 198 | } 199 | 200 | private unwrapData (reqP: Promise>, unwrap = true, includeEtag = false) { 201 | if (!unwrap) { 202 | // For raw responses, etag is available via response.headers 203 | return reqP 204 | } 205 | return reqP.then(response => { 206 | const data = response.data 207 | if (!includeEtag) return data 208 | const etag = response.headers.etag || response.headers['ETag'] 209 | return { ...data, status: response.status, etag } 210 | }) 211 | } 212 | } 213 | 214 | export default API 215 | -------------------------------------------------------------------------------- /nodejs/src/type.ts: -------------------------------------------------------------------------------- 1 | export enum TeamVisibilityType { 2 | PUBLIC = 'public', 3 | PRIVATE = 'private', 4 | } 5 | 6 | export enum NotePublishType { 7 | EDIT = 'edit', 8 | VIEW = 'view', 9 | SLIDE = 'slide', 10 | BOOK = 'book' 11 | } 12 | 13 | export enum CommentPermissionType { 14 | DISABLED = 'disabled', 15 | FORBIDDEN = 'forbidden', 16 | OWNERS = 'owners', 17 | SIGNED_IN_USERS = 'signed_in_users', 18 | EVERYONE = 'everyone' 19 | } 20 | 21 | export type CreateNoteOptions = { 22 | title?: string 23 | content?: string 24 | readPermission?: NotePermissionRole, 25 | writePermission?: NotePermissionRole, 26 | commentPermission?: CommentPermissionType, 27 | permalink?: string 28 | } 29 | 30 | export type Team = { 31 | id: string 32 | ownerId: string 33 | name: string 34 | logo: string 35 | path: string 36 | description: string 37 | hardBreaks: boolean 38 | visibility: TeamVisibilityType 39 | createdAt: Date 40 | } 41 | 42 | export type User = { 43 | id: string 44 | email: string | null 45 | name: string 46 | userPath: string 47 | photo: string 48 | teams: Team[] 49 | } 50 | 51 | export type SimpleUserProfile = { 52 | name: string, 53 | userPath: string 54 | photo: string 55 | biography: string | null 56 | createdAt: Date 57 | } 58 | 59 | export enum NotePermissionRole { 60 | OWNER = 'owner', 61 | SIGNED_IN = 'signed_in', 62 | GUEST = 'guest' 63 | } 64 | 65 | export type Note = { 66 | id: string 67 | title: string 68 | tags: string[] 69 | lastChangedAt: string 70 | createdAt: string 71 | lastChangeUser: SimpleUserProfile | null 72 | publishType: NotePublishType 73 | publishedAt: string | null 74 | userPath: string | null 75 | teamPath: string | null 76 | permalink: string | null 77 | shortId: string 78 | publishLink: string 79 | 80 | readPermission: NotePermissionRole 81 | writePermission: NotePermissionRole 82 | } 83 | 84 | export type SingleNote = Note & { 85 | content: string 86 | } 87 | 88 | // User 89 | export type GetMe = User 90 | 91 | // User notes 92 | export type GetUserNotes = Note[] 93 | export type GetUserNote = SingleNote 94 | export type GetUserHistory = Note[] 95 | export type CreateUserNote = SingleNote 96 | export type UpdateUserNote = void 97 | export type DeleteUserNote = void 98 | 99 | // Teams 100 | export type GetUserTeams = Team[] 101 | 102 | // Team notes 103 | export type GetTeamNotes = Note[] 104 | export type CreateTeamNote = SingleNote 105 | export type UpdateTeamNote = void 106 | export type DeleteTeamNote = void 107 | 108 | 109 | -------------------------------------------------------------------------------- /nodejs/tests/api.spec.ts: -------------------------------------------------------------------------------- 1 | import { server } from './mock' 2 | import { API } from '../src' 3 | import { http, HttpResponse } from 'msw' 4 | import { TooManyRequestsError } from '../src/error' 5 | 6 | let client: API 7 | 8 | beforeAll(() => { 9 | client = new API(process.env.HACKMD_ACCESS_TOKEN!) 10 | 11 | return server.listen() 12 | }) 13 | 14 | afterEach(() => { 15 | server.resetHandlers() 16 | }) 17 | 18 | afterAll(() => { 19 | server.close() 20 | // Add explicit cleanup to ensure Jest exits properly 21 | return new Promise(resolve => setTimeout(resolve, 100)) 22 | }) 23 | 24 | test('getMe', async () => { 25 | const response = await client.getMe({ unwrapData: false }) 26 | 27 | expect(response).toHaveProperty('status', 200) 28 | expect(response).toHaveProperty('headers') 29 | }) 30 | 31 | test('getMe unwrapped', async () => { 32 | const response = await client.getMe() 33 | 34 | expect(typeof response).toBe('object') 35 | expect(response).toHaveProperty('id') 36 | expect(response).toHaveProperty('name') 37 | expect(response).toHaveProperty('email') 38 | expect(response).toHaveProperty('userPath') 39 | expect(response).toHaveProperty('photo') 40 | }) 41 | 42 | test('should throw axios error object if set wrapResponseErrors to false', async () => { 43 | const customCilent = new API(process.env.HACKMD_ACCESS_TOKEN!, undefined, { 44 | wrapResponseErrors: false, 45 | }) 46 | 47 | server.use( 48 | http.get('https://api.hackmd.io/v1/me', () => { 49 | return new HttpResponse(null, { 50 | status: 429 51 | }) 52 | }) 53 | ) 54 | 55 | try { 56 | await customCilent.getMe() 57 | } catch (error: any) { 58 | expect(error).toHaveProperty('response') 59 | expect(error.response).toHaveProperty('status', 429) 60 | } 61 | }) 62 | 63 | test('should throw HackMD error object', async () => { 64 | // Create a client with retry disabled to avoid conflicts with error handling test 65 | const clientWithoutRetry = new API(process.env.HACKMD_ACCESS_TOKEN!, undefined, { 66 | wrapResponseErrors: true, 67 | retryConfig: undefined // Disable retry logic for this test 68 | }) 69 | 70 | server.use( 71 | http.get('https://api.hackmd.io/v1/me', () => { 72 | return HttpResponse.json( 73 | {}, 74 | { 75 | status: 429, 76 | headers: { 77 | 'X-RateLimit-UserLimit': '100', 78 | 'x-RateLimit-UserRemaining': '0', 79 | 'x-RateLimit-UserReset': String( 80 | new Date().getTime() + 1000 * 60 * 60 * 24, 81 | ), 82 | }, 83 | } 84 | ) 85 | }) 86 | ) 87 | 88 | try { 89 | await clientWithoutRetry.getMe() 90 | // If we get here, the test should fail because an error wasn't thrown 91 | expect('no error thrown').toBe('error should have been thrown') 92 | } catch (error: any) { 93 | expect(error).toBeInstanceOf(TooManyRequestsError) 94 | expect(error).toHaveProperty('code', 429) 95 | expect(error).toHaveProperty('statusText', 'Too Many Requests') 96 | expect(error).toHaveProperty('userLimit', 100) 97 | expect(error).toHaveProperty('userRemaining', 0) 98 | expect(error).toHaveProperty('resetAfter') 99 | } 100 | }) 101 | -------------------------------------------------------------------------------- /nodejs/tests/etag.spec.ts: -------------------------------------------------------------------------------- 1 | import { server } from './mock' 2 | import { API } from '../src' 3 | import { http, HttpResponse } from 'msw' 4 | 5 | let client: API 6 | 7 | beforeAll(() => { 8 | client = new API(process.env.HACKMD_ACCESS_TOKEN!) 9 | return server.listen() 10 | }) 11 | 12 | afterEach(() => { 13 | server.resetHandlers() 14 | }) 15 | 16 | afterAll(() => { 17 | server.close() 18 | // Add explicit cleanup to ensure Jest exits properly 19 | return new Promise(resolve => setTimeout(resolve, 100)) 20 | }) 21 | 22 | describe('Etag support', () => { 23 | // Helper to reset server between tests 24 | beforeEach(() => { 25 | server.resetHandlers() 26 | }) 27 | 28 | describe('getNote', () => { 29 | test('should include etag property in response when unwrapData is true', async () => { 30 | // Setup mock server to return an etag 31 | const mockEtag = 'W/"123456789"' 32 | 33 | server.use( 34 | http.get('https://api.hackmd.io/v1/notes/test-note-id', () => { 35 | return HttpResponse.json( 36 | { 37 | id: 'test-note-id', 38 | title: 'Test Note' 39 | }, 40 | { 41 | headers: { 42 | 'ETag': mockEtag 43 | } 44 | } 45 | ) 46 | }) 47 | ) 48 | 49 | // Make request with default unwrapData: true 50 | const response = await client.getNote('test-note-id') 51 | 52 | // Verify response has etag property 53 | expect(response).toHaveProperty('etag', mockEtag) 54 | 55 | // Verify data properties still exist 56 | expect(response).toHaveProperty('id', 'test-note-id') 57 | expect(response).toHaveProperty('title', 'Test Note') 58 | }) 59 | 60 | test('should include etag in response headers when unwrapData is false', async () => { 61 | // Setup mock server to return an etag 62 | const mockEtag = 'W/"123456789"' 63 | 64 | server.use( 65 | http.get('https://api.hackmd.io/v1/notes/test-note-id', () => { 66 | return HttpResponse.json( 67 | { 68 | id: 'test-note-id', 69 | title: 'Test Note' 70 | }, 71 | { 72 | headers: { 73 | 'ETag': mockEtag 74 | } 75 | } 76 | ) 77 | }) 78 | ) 79 | 80 | // Make request with unwrapData: false 81 | const response = await client.getNote('test-note-id', { unwrapData: false }) 82 | 83 | // Verify response headers contain etag 84 | expect(response.headers.etag).toBe(mockEtag) 85 | 86 | // Verify data is in response.data 87 | expect(response.data).toHaveProperty('id', 'test-note-id') 88 | expect(response.data).toHaveProperty('title', 'Test Note') 89 | }) 90 | 91 | test('should send etag in If-None-Match header when provided in options', async () => { 92 | // Setup mock server to check for If-None-Match header 93 | let ifNoneMatchValue: string | null = null 94 | const mockEtag = 'W/"123456789"' 95 | 96 | server.use( 97 | http.get('https://api.hackmd.io/v1/notes/test-note-id', ({ request }) => { 98 | // Store the If-None-Match header value for verification 99 | ifNoneMatchValue = request.headers.get('If-None-Match') 100 | 101 | return HttpResponse.json( 102 | { 103 | id: 'test-note-id', 104 | title: 'Test Note' 105 | }, 106 | { 107 | headers: { 108 | 'ETag': mockEtag 109 | } 110 | } 111 | ) 112 | }) 113 | ) 114 | 115 | // Make request with etag in options 116 | await client.getNote('test-note-id', { etag: mockEtag }) 117 | 118 | // Verify the If-None-Match header was sent with correct value 119 | expect(ifNoneMatchValue).toBe(mockEtag) 120 | }) 121 | 122 | test('should preserve 304 status and etag when unwrapData is false and content not modified', async () => { 123 | // Setup mock server to return 304 when etag matches 124 | const mockEtag = 'W/"123456789"' 125 | 126 | server.use( 127 | http.get('https://api.hackmd.io/v1/notes/test-note-id', ({ request }) => { 128 | const ifNoneMatch = request.headers.get('If-None-Match') 129 | 130 | // Return 304 when etag matches 131 | if (ifNoneMatch === mockEtag) { 132 | return new HttpResponse(null, { 133 | status: 304, 134 | headers: { 135 | 'ETag': mockEtag 136 | } 137 | }) 138 | } 139 | 140 | return HttpResponse.json( 141 | { 142 | id: 'test-note-id', 143 | title: 'Test Note' 144 | }, 145 | { 146 | headers: { 147 | 'ETag': mockEtag 148 | } 149 | } 150 | ) 151 | }) 152 | ) 153 | 154 | // Request with unwrapData: false to get full response including status 155 | const response = await client.getNote('test-note-id', { etag: mockEtag, unwrapData: false }) 156 | 157 | // Verify we get a 304 status code 158 | expect(response.status).toBe(304) 159 | 160 | // Verify etag is still available in headers 161 | expect(response.headers.etag).toBe(mockEtag) 162 | }) 163 | 164 | test('should return status and etag only when unwrapData is true and content not modified', async () => { 165 | // Setup mock server to return 304 when etag matches 166 | const mockEtag = 'W/"123456789"' 167 | 168 | server.use( 169 | http.get('https://api.hackmd.io/v1/notes/test-note-id', ({ request }) => { 170 | const ifNoneMatch = request.headers.get('If-None-Match') 171 | 172 | // Return 304 when etag matches 173 | if (ifNoneMatch === mockEtag) { 174 | return new HttpResponse(null, { 175 | status: 304, 176 | headers: { 177 | 'ETag': mockEtag 178 | } 179 | }) 180 | } 181 | 182 | return HttpResponse.json( 183 | { 184 | id: 'test-note-id', 185 | title: 'Test Note' 186 | }, 187 | { 188 | headers: { 189 | 'ETag': mockEtag 190 | } 191 | } 192 | ) 193 | }) 194 | ) 195 | 196 | // Request with default unwrapData: true 197 | const response = await client.getNote('test-note-id', { etag: mockEtag }) 198 | 199 | // With unwrapData: true and a 304 response, we just get the etag 200 | expect(response).toHaveProperty('etag', mockEtag) 201 | expect(response).toHaveProperty('status', 304) 202 | }) 203 | }) 204 | 205 | describe('createNote', () => { 206 | test('should include etag property in response when creating a note', async () => { 207 | // Setup mock server to return an etag 208 | const mockEtag = 'W/"abcdef123"' 209 | 210 | server.use( 211 | http.post('https://api.hackmd.io/v1/notes', () => { 212 | return HttpResponse.json( 213 | { 214 | id: 'new-note-id', 215 | title: 'New Test Note' 216 | }, 217 | { 218 | headers: { 219 | 'ETag': mockEtag 220 | } 221 | } 222 | ) 223 | }) 224 | ) 225 | 226 | // Make request with default unwrapData: true 227 | const response = await client.createNote({ title: 'New Test Note', content: 'Test content' }) 228 | 229 | // Verify response has etag property 230 | expect(response).toHaveProperty('etag', mockEtag) 231 | 232 | // Verify data properties still exist 233 | expect(response).toHaveProperty('id', 'new-note-id') 234 | expect(response).toHaveProperty('title', 'New Test Note') 235 | }) 236 | }) 237 | 238 | describe('updateNote', () => { 239 | test('should include etag property in response when updating note content', async () => { 240 | // Setup mock server to return an etag 241 | const mockEtag = 'W/"updated-etag"' 242 | 243 | server.use( 244 | http.patch('https://api.hackmd.io/v1/notes/test-note-id', () => { 245 | return HttpResponse.json( 246 | { 247 | id: 'test-note-id', 248 | title: 'Updated Test Note', 249 | content: 'Updated content via updateNote' 250 | }, 251 | { 252 | headers: { 253 | 'ETag': mockEtag 254 | } 255 | } 256 | ) 257 | }) 258 | ) 259 | 260 | // Make request with default unwrapData: true 261 | const response = await client.updateNoteContent('test-note-id', 'Updated content') 262 | 263 | // Verify response has etag property 264 | expect(response).toHaveProperty('etag', mockEtag) 265 | 266 | // Verify data properties still exist 267 | expect(response).toHaveProperty('id', 'test-note-id') 268 | expect(response).toHaveProperty('title', 'Updated Test Note') 269 | expect(response).toHaveProperty('content', 'Updated content via updateNote') 270 | }) 271 | }) 272 | }) 273 | -------------------------------------------------------------------------------- /nodejs/tests/mock/handlers.ts: -------------------------------------------------------------------------------- 1 | import { http, HttpResponse } from 'msw' 2 | 3 | export const handlers = [ 4 | http.get('/posts', () => { 5 | return HttpResponse.json(null) 6 | }), 7 | ] 8 | -------------------------------------------------------------------------------- /nodejs/tests/mock/index.ts: -------------------------------------------------------------------------------- 1 | import { http, HttpResponse } from 'msw' 2 | import { setupServer } from 'msw/node' 3 | import { faker } from '@faker-js/faker' 4 | 5 | const checkBearerToken = (request: Request) => { 6 | const authHeader = request.headers.get('Authorization') 7 | const token = authHeader?.split(' ')[1] 8 | 9 | return token === process.env.HACKMD_ACCESS_TOKEN 10 | } 11 | 12 | // In MSW v2, we don't need the withAuthorization wrapper - we can handle auth directly in the handler 13 | export const server = setupServer( 14 | http.get('https://api.hackmd.io/v1/me', ({ request }) => { 15 | // Check authorization 16 | if (!checkBearerToken(request)) { 17 | return HttpResponse.json( 18 | { error: 'Unauthorized' }, 19 | { status: 401 } 20 | ) 21 | } 22 | 23 | // Return successful response with mock user data 24 | return HttpResponse.json({ 25 | id: faker.datatype.uuid(), 26 | name: faker.name.fullName(), 27 | email: faker.internet.email(), 28 | userPath: faker.internet.userName(), 29 | photo: faker.image.avatar(), 30 | teams: [] 31 | }) 32 | }), 33 | ) 34 | -------------------------------------------------------------------------------- /nodejs/tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "importHelpers": true, 5 | "module": "ESNext", 6 | "moduleResolution": "node", 7 | "outDir": "dist", 8 | "rootDir": ".", 9 | "strict": true, 10 | "target": "ESNext", 11 | "esModuleInterop": true, 12 | "resolveJsonModule": true, 13 | "allowSyntheticDefaultImports": true 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /nodejs/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "rootDir": "src" 5 | }, 6 | "include": [ 7 | "src/**/*" 8 | ] 9 | } -------------------------------------------------------------------------------- /nodejs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": [ 4 | "**/*" 5 | ] 6 | } 7 | --------------------------------------------------------------------------------