Skip to content

Commit 3056cea

Browse files
committed
feat: add etag support to getNote method
- Add etag field to RequestOptions type - Implement conditional requests with If-None-Match header - Add proper handling of 304 Not Modified responses - Extract etag from response headers - Include etag in response data when requested - Add comprehensive test coverage for etag functionality
1 parent 4229fb1 commit 3056cea

File tree

2 files changed

+221
-6
lines changed

2 files changed

+221
-6
lines changed

nodejs/src/index.ts

+19-6
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { CreateNoteOptions, GetMe, GetUserHistory, GetUserNotes, GetUserNote, Cr
33
import * as HackMDErrors from './error'
44

55
export type RequestOptions = {
6-
unwrapData?: boolean
6+
unwrapData?: boolean;
7+
etag?: string | undefined;
78
}
89

910
const defaultOption: RequestOptions = {
@@ -146,7 +147,14 @@ export class API {
146147
}
147148

148149
async getNote<Opt extends RequestOptions> (noteId: string, options = defaultOption as Opt): Promise<OptionReturnType<Opt, GetUserNote>> {
149-
return this.unwrapData(this.axios.get<GetUserNote>(`notes/${noteId}`), options.unwrapData) as unknown as OptionReturnType<Opt, GetUserNote>
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<GetUserNote>(`notes/${noteId}`, config)
157+
return this.unwrapData(request, options.unwrapData, true) as unknown as OptionReturnType<Opt, GetUserNote>
150158
}
151159

152160
async createNote<Opt extends RequestOptions> (payload: CreateNoteOptions, options = defaultOption as Opt): Promise<OptionReturnType<Opt, CreateUserNote>> {
@@ -189,12 +197,17 @@ export class API {
189197
return this.axios.delete<AxiosResponse>(`teams/${teamPath}/notes/${noteId}`)
190198
}
191199

192-
private unwrapData<T> (reqP: Promise<AxiosResponse<T>>, unwrap = true) {
193-
if (unwrap) {
194-
return reqP.then(response => response.data)
195-
} else {
200+
private unwrapData<T> (reqP: Promise<AxiosResponse<T>>, unwrap = true, includeEtag = false) {
201+
if (!unwrap) {
202+
// For raw responses, etag is available via response.headers
196203
return reqP
197204
}
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+
})
198211
}
199212
}
200213

nodejs/tests/etag.spec.ts

+202
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
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+
test('response includes etag when server provides it (unwrapData: true)', async () => {
29+
// Setup mock server to return an etag
30+
const mockEtag = 'W/"123456789"'
31+
32+
server.use(
33+
http.get('https://api.hackmd.io/v1/notes/test-note-id', () => {
34+
return HttpResponse.json(
35+
{
36+
id: 'test-note-id',
37+
title: 'Test Note'
38+
},
39+
{
40+
headers: {
41+
'ETag': mockEtag
42+
}
43+
}
44+
)
45+
})
46+
)
47+
48+
// Make request with default unwrapData: true
49+
const response = await client.getNote('test-note-id')
50+
51+
// Verify response has etag property
52+
expect(response).toHaveProperty('etag', mockEtag)
53+
54+
// Verify data properties still exist
55+
expect(response).toHaveProperty('id', 'test-note-id')
56+
expect(response).toHaveProperty('title', 'Test Note')
57+
})
58+
59+
test('response includes etag in headers when unwrapData is false', async () => {
60+
// Setup mock server to return an etag
61+
const mockEtag = 'W/"123456789"'
62+
63+
server.use(
64+
http.get('https://api.hackmd.io/v1/notes/test-note-id', () => {
65+
return HttpResponse.json(
66+
{
67+
id: 'test-note-id',
68+
title: 'Test Note'
69+
},
70+
{
71+
headers: {
72+
'ETag': mockEtag
73+
}
74+
}
75+
)
76+
})
77+
)
78+
79+
// Make request with unwrapData: false
80+
const response = await client.getNote('test-note-id', { unwrapData: false })
81+
82+
// Verify response headers contain etag
83+
expect(response.headers.etag).toBe(mockEtag)
84+
85+
// Verify data is in response.data
86+
expect(response.data).toHaveProperty('id', 'test-note-id')
87+
expect(response.data).toHaveProperty('title', 'Test Note')
88+
})
89+
90+
test('sends If-None-Match header when etag is provided', async () => {
91+
// Setup mock server to check for If-None-Match header
92+
let ifNoneMatchValue: string | null = null
93+
const mockEtag = 'W/"123456789"'
94+
95+
server.use(
96+
http.get('https://api.hackmd.io/v1/notes/test-note-id', ({ request }) => {
97+
// Store the If-None-Match header value for verification
98+
ifNoneMatchValue = request.headers.get('If-None-Match')
99+
100+
return HttpResponse.json(
101+
{
102+
id: 'test-note-id',
103+
title: 'Test Note'
104+
},
105+
{
106+
headers: {
107+
'ETag': mockEtag
108+
}
109+
}
110+
)
111+
})
112+
)
113+
114+
// Make request with etag in options
115+
await client.getNote('test-note-id', { etag: mockEtag })
116+
117+
// Verify the If-None-Match header was sent with correct value
118+
expect(ifNoneMatchValue).toBe(mockEtag)
119+
})
120+
121+
test('handles 304 Not Modified responses correctly (unwrapData: false)', async () => {
122+
// Setup mock server to return 304 when etag matches
123+
const mockEtag = 'W/"123456789"'
124+
125+
server.use(
126+
http.get('https://api.hackmd.io/v1/notes/test-note-id', ({ request }) => {
127+
const ifNoneMatch = request.headers.get('If-None-Match')
128+
129+
// Return 304 when etag matches
130+
if (ifNoneMatch === mockEtag) {
131+
return new HttpResponse(null, {
132+
status: 304,
133+
headers: {
134+
'ETag': mockEtag
135+
}
136+
})
137+
}
138+
139+
return HttpResponse.json(
140+
{
141+
id: 'test-note-id',
142+
title: 'Test Note'
143+
},
144+
{
145+
headers: {
146+
'ETag': mockEtag
147+
}
148+
}
149+
)
150+
})
151+
)
152+
153+
// Request with unwrapData: false to get full response including status
154+
const response = await client.getNote('test-note-id', { etag: mockEtag, unwrapData: false })
155+
156+
// Verify we get a 304 status code
157+
expect(response.status).toBe(304)
158+
159+
// Verify etag is still available in headers
160+
expect(response.headers.etag).toBe(mockEtag)
161+
})
162+
163+
test('handles 304 Not Modified responses correctly (unwrapData: true)', async () => {
164+
// Setup mock server to return 304 when etag matches
165+
const mockEtag = 'W/"123456789"'
166+
167+
server.use(
168+
http.get('https://api.hackmd.io/v1/notes/test-note-id', ({ request }) => {
169+
const ifNoneMatch = request.headers.get('If-None-Match')
170+
171+
// Return 304 when etag matches
172+
if (ifNoneMatch === mockEtag) {
173+
return new HttpResponse(null, {
174+
status: 304,
175+
headers: {
176+
'ETag': mockEtag
177+
}
178+
})
179+
}
180+
181+
return HttpResponse.json(
182+
{
183+
id: 'test-note-id',
184+
title: 'Test Note'
185+
},
186+
{
187+
headers: {
188+
'ETag': mockEtag
189+
}
190+
}
191+
)
192+
})
193+
)
194+
195+
// Request with default unwrapData: true
196+
const response = await client.getNote('test-note-id', { etag: mockEtag })
197+
198+
// With unwrapData: true and a 304 response, we just get the etag
199+
expect(response).toHaveProperty('etag', mockEtag)
200+
expect(response).toHaveProperty('status', 304)
201+
})
202+
})

0 commit comments

Comments
 (0)