Skip to content

Commit 8db7792

Browse files
SgtPookilidelachingbrain
authored
fix: verified-fetch etag header (#434)
Fixes the formatting for the etag header --------- Co-authored-by: Marcin Rataj <lidel@lidel.org> Co-authored-by: Alex Potsides <alex@achingbrain.net>
1 parent 754c7af commit 8db7792

File tree

5 files changed

+81
-4
lines changed

5 files changed

+81
-4
lines changed

packages/verified-fetch/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type RequestFormatShorthand = 'raw' | 'car' | 'tar' | 'ipns-record' | 'dag-json' | 'dag-cbor' | 'json' | 'cbor'
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { RequestFormatShorthand } from '../types.js'
2+
import type { CID } from 'multiformats/cid'
3+
4+
interface GetETagArg {
5+
cid: CID
6+
reqFormat?: RequestFormatShorthand
7+
rangeStart?: number
8+
rangeEnd?: number
9+
/**
10+
* Weak Etag is used when we can't guarantee byte-for-byte-determinism (generated, or mutable content).
11+
* Some examples:
12+
* - IPNS requests
13+
* - CAR streamed with blocks in non-deterministic order
14+
* - TAR streamed with files in non-deterministic order
15+
*/
16+
weak?: boolean
17+
}
18+
19+
/**
20+
* etag
21+
* you need to wrap cid with ""
22+
* we use strong Etags for immutable responses and weak one (prefixed with W/ ) for mutable/generated ones (ipns and generated HTML).
23+
* block and car responses should have different etag than deserialized one, so you can add some prefix like we do in existing gateway
24+
*
25+
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
26+
* @see https://specs.ipfs.tech/http-gateways/path-gateway/#etag-response-header
27+
*/
28+
export function getETag ({ cid, reqFormat, weak, rangeStart, rangeEnd }: GetETagArg): string {
29+
const prefix = weak === true ? 'W/' : ''
30+
let suffix = reqFormat == null ? '' : `.${reqFormat}`
31+
if (rangeStart != null || rangeEnd != null) {
32+
suffix += `.${rangeStart ?? '0'}-${rangeEnd ?? 'N'}`
33+
}
34+
35+
return `${prefix}"${cid.toString()}${suffix}"`
36+
}

packages/verified-fetch/src/utils/parse-url-string.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { peerIdFromString } from '@libp2p/peer-id'
22
import { CID } from 'multiformats/cid'
33
import { TLRU } from './tlru.js'
4+
import type { RequestFormatShorthand } from '../types.js'
45
import type { IPNS, IPNSRoutingEvents, ResolveDnsLinkProgressEvents, ResolveProgressEvents, ResolveResult } from '@helia/ipns'
56
import type { ComponentLogger } from '@libp2p/interface'
67
import type { ProgressOptions } from 'progress-events'
@@ -16,11 +17,15 @@ export interface ParseUrlStringOptions extends ProgressOptions<ResolveProgressEv
1617

1718
}
1819

20+
export interface ParsedUrlQuery extends Record<string, string | unknown> {
21+
format?: RequestFormatShorthand
22+
}
23+
1924
export interface ParsedUrlStringResults {
2025
protocol: string
2126
path: string
2227
cid: CID
23-
query: Record<string, string>
28+
query: ParsedUrlQuery
2429
}
2530

2631
const URL_REGEX = /^(?<protocol>ip[fn]s):\/\/(?<cidOrPeerIdOrDnsLink>[^/$?]+)\/?(?<path>[^$?]*)\??(?<queryString>.*)$/

packages/verified-fetch/src/verified-fetch.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ import { code as rawCode } from 'multiformats/codecs/raw'
99
import { identity } from 'multiformats/hashes/identity'
1010
import { CustomProgressEvent } from 'progress-events'
1111
import { dagCborToSafeJSON } from './utils/dag-cbor-to-safe-json.js'
12+
import { getETag } from './utils/get-e-tag.js'
1213
import { getStreamFromAsyncIterable } from './utils/get-stream-from-async-iterable.js'
1314
import { parseResource } from './utils/parse-resource.js'
1415
import { walkPath, type PathWalkerFn } from './utils/walk-path.js'
1516
import type { CIDDetail, ContentTypeParser, Resource, VerifiedFetchInit as VerifiedFetchOptions } from './index.js'
17+
import type { RequestFormatShorthand } from './types.js'
1618
import type { Helia } from '@helia/interface'
1719
import type { AbortOptions, Logger } from '@libp2p/interface'
1820
import type { UnixFSEntry } from 'ipfs-unixfs-exporter'
@@ -231,8 +233,8 @@ export class VerifiedFetch {
231233
* @see https://specs.ipfs.tech/http-gateways/path-gateway/#format-request-query-parameter
232234
* @default 'raw'
233235
*/
234-
private getFormat ({ headerFormat, queryFormat }: { headerFormat: string | null, queryFormat: string | null }): string | null {
235-
const formatMap: Record<string, string> = {
236+
private getFormat ({ headerFormat, queryFormat }: { headerFormat: string | null, queryFormat: RequestFormatShorthand | null }): RequestFormatShorthand | null {
237+
const formatMap: Record<string, RequestFormatShorthand> = {
236238
'vnd.ipld.raw': 'raw',
237239
'vnd.ipld.car': 'car',
238240
'application/x-tar': 'tar',
@@ -323,7 +325,7 @@ export class VerifiedFetch {
323325
}
324326
}
325327

326-
response.headers.set('etag', cid.toString()) // https://specs.ipfs.tech/http-gateways/path-gateway/#etag-response-header
328+
response.headers.set('etag', getETag({ cid, reqFormat: format ?? undefined, weak: false }))
327329
response.headers.set('cache-control', 'public, max-age=29030400, immutable')
328330
response.headers.set('X-Ipfs-Path', resource.toString()) // https://specs.ipfs.tech/http-gateways/path-gateway/#x-ipfs-path-response-header
329331

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { expect } from 'aegir/chai'
2+
import { CID } from 'multiformats/cid'
3+
import { getETag } from '../src/utils/get-e-tag.js'
4+
5+
const cidString = 'QmQJ8fxavY54CUsxMSx9aE9Rdcmvhx8awJK2jzJp4iAqCr'
6+
const testCID = CID.parse(cidString)
7+
8+
describe('getETag', () => {
9+
it('CID eTag', () => {
10+
expect(getETag({ cid: testCID, weak: true })).to.equal(`W/"${cidString}"`)
11+
expect(getETag({ cid: testCID, weak: false })).to.equal(`"${cidString}"`)
12+
})
13+
14+
it('should return ETag with CID and format suffix', () => {
15+
expect(getETag({ cid: testCID, reqFormat: 'raw' })).to.equal(`"${cidString}.raw"`)
16+
expect(getETag({ cid: testCID, reqFormat: 'json' })).to.equal(`"${cidString}.json"`)
17+
})
18+
19+
it('should return ETag with CID and range suffix', () => {
20+
expect(getETag({ cid: testCID, weak: true, reqFormat: 'car', rangeStart: 10, rangeEnd: 20 })).to.equal(`W/"${cidString}.car.10-20"`)
21+
expect(getETag({ cid: testCID, weak: false, reqFormat: 'car', rangeStart: 10, rangeEnd: 20 })).to.equal(`"${cidString}.car.10-20"`)
22+
})
23+
24+
it('should return ETag with CID, format and range suffix', () => {
25+
expect(getETag({ cid: testCID, reqFormat: 'raw', weak: false, rangeStart: 10, rangeEnd: 20 })).to.equal(`"${cidString}.raw.10-20"`)
26+
})
27+
28+
it('should handle undefined rangeStart and rangeEnd', () => {
29+
expect(getETag({ cid: testCID, reqFormat: 'raw', weak: false, rangeStart: undefined, rangeEnd: undefined })).to.equal(`"${cidString}.raw"`)
30+
expect(getETag({ cid: testCID, reqFormat: 'raw', weak: false, rangeStart: 55, rangeEnd: undefined })).to.equal(`"${cidString}.raw.55-N"`)
31+
expect(getETag({ cid: testCID, reqFormat: 'raw', weak: false, rangeStart: undefined, rangeEnd: 77 })).to.equal(`"${cidString}.raw.0-77"`)
32+
})
33+
})

0 commit comments

Comments
 (0)