Skip to content
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

feat: support Accept header in @helia/verified-fetch #438

Merged
merged 10 commits into from
Feb 22, 2024
33 changes: 33 additions & 0 deletions packages/verified-fetch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,39 @@ if (res.headers.get('Content-Type') === 'application/json') {
console.info(obj) // ...
```

## The `Accept` header

The `Accept` header can be passed to override certain response processing, or to ensure that the final `Content-Type` of the response is the one that is expected.

If the final `Content-Type` does not match the `Accept` header, or if the content cannot be represented in the format dictated by the `Accept` header, or you have configured a custom content type parser, and that parser returns a value that isn't in the accept header, a [406: Not Acceptible](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/406) response will be returned:

```typescript
import { verifiedFetch } from '@helia/verified-fetch'

const res = await verifiedFetch('ipfs://bafyJPEGImageCID', {
headers: {
accept: 'image/png'
}
})

console.info(res.status) // 406 - the image was a JPEG but we specified PNG as the accept header
```

It can also be used to skip processing the data from some formats such as `DAG-CBOR` if you wish to handle decoding it yourself:

```typescript
import { verifiedFetch } from '@helia/verified-fetch'

const res = await verifiedFetch('ipfs://bafyDAGCBORCID', {
headers: {
accept: 'application/octet-stream'
}
})

console.info(res.headers.get('accept')) // application/octet-stream
const buf = await res.arrayBuffer() // raw bytes, not processed as JSON
```

## Comparison to fetch

This module attempts to act as similarly to the `fetch()` API as possible.
Expand Down
37 changes: 37 additions & 0 deletions packages/verified-fetch/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,39 @@
* console.info(obj) // ...
* ```
*
* ## The `Accept` header
*
* The `Accept` header can be passed to override certain response processing, or to ensure that the final `Content-Type` of the response is the one that is expected.
*
* If the final `Content-Type` does not match the `Accept` header, or if the content cannot be represented in the format dictated by the `Accept` header, or you have configured a custom content type parser, and that parser returns a value that isn't in the accept header, a [406: Not Acceptible](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/406) response will be returned:
achingbrain marked this conversation as resolved.
Show resolved Hide resolved
*
* ```typescript
* import { verifiedFetch } from '@helia/verified-fetch'
*
* const res = await verifiedFetch('ipfs://bafyJPEGImageCID', {
* headers: {
* accept: 'image/png'
* }
* })
*
* console.info(res.status) // 406 - the image was a JPEG but we specified PNG as the accept header
* ```
*
* It can also be used to skip processing the data from some formats such as `DAG-CBOR` if you wish to handle decoding it yourself:
*
* ```typescript
* import { verifiedFetch } from '@helia/verified-fetch'
*
* const res = await verifiedFetch('ipfs://bafyDAGCBORCID', {
* headers: {
* accept: 'application/octet-stream'
* }
* })
*
* console.info(res.headers.get('accept')) // application/octet-stream
* const buf = await res.arrayBuffer() // raw bytes, not processed as JSON
* ```
*
* ## Comparison to fetch
*
* This module attempts to act as similarly to the `fetch()` API as possible.
Expand Down Expand Up @@ -449,6 +482,10 @@ import type { ProgressEvent, ProgressOptions } from 'progress-events'
*/
export type Resource = string | CID

export interface ResourceDetail {
resource: Resource
}

export interface CIDDetail {
cid: CID
path: string
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Takes a filename URL param and returns a string for use in a
* `Content-Disposition` header
*/
export function getContentDispositionFilename (filename: string): string {
const asciiOnly = replaceNonAsciiCharacters(filename)

if (asciiOnly === filename) {
return `filename="${filename}"`
}

return `filename="${asciiOnly}"; filename*=UTF-8''${encodeURIComponent(filename)}`
}

function replaceNonAsciiCharacters (filename: string): string {
// eslint-disable-next-line no-control-regex
return filename.replace(/[^\x00-\x7F]/g, '_')
}
12 changes: 11 additions & 1 deletion packages/verified-fetch/src/utils/parse-url-string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

export interface ParsedUrlQuery extends Record<string, string | unknown> {
format?: RequestFormatShorthand
download?: boolean
filename?: string
}

export interface ParsedUrlStringResults {
Expand Down Expand Up @@ -109,14 +111,22 @@
}

// parse query string
const query: Record<string, string> = {}
const query: Record<string, any> = {}

if (queryString != null && queryString.length > 0) {
const queryParts = queryString.split('&')
for (const part of queryParts) {
const [key, value] = part.split('=')
query[key] = decodeURIComponent(value)
}

if (query.download != null) {
query.download = query.download === 'true'
}

Check warning on line 125 in packages/verified-fetch/src/utils/parse-url-string.ts

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L124 - L125 were not covered by tests

if (query.filename != null) {
query.filename = query.filename.toString()
}

Check warning on line 129 in packages/verified-fetch/src/utils/parse-url-string.ts

View check run for this annotation

Codecov / codecov/patch

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

Added lines #L128 - L129 were not covered by tests
}

/**
Expand Down
163 changes: 163 additions & 0 deletions packages/verified-fetch/src/utils/select-output-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { code as dagCborCode } from '@ipld/dag-cbor'
import { code as dagJsonCode } from '@ipld/dag-json'
import { code as dagPbCode } from '@ipld/dag-pb'
import { code as jsonCode } from 'multiformats/codecs/json'
import { code as rawCode } from 'multiformats/codecs/raw'
import type { RequestFormatShorthand } from '../types.js'
import type { CID } from 'multiformats/cid'

const CID_TYPE_MAP: Record<number, string[]> = {
achingbrain marked this conversation as resolved.
Show resolved Hide resolved
[dagCborCode]: [
'application/json',
'application/vnd.ipld.dag-cbor',
'application/cbor',
'application/vnd.ipld.dag-json',
'application/octet-stream',
'application/vnd.ipld.raw',
'application/vnd.ipfs.ipns-record',
2color marked this conversation as resolved.
Show resolved Hide resolved
'application/vnd.ipld.car'
],
[dagJsonCode]: [
'application/json',
'application/vnd.ipld.dag-cbor',
'application/cbor',
'application/vnd.ipld.dag-json',
'application/octet-stream',
'application/vnd.ipld.raw',
'application/vnd.ipfs.ipns-record',
'application/vnd.ipld.car'
],
[jsonCode]: [
'application/json',
'application/vnd.ipld.dag-cbor',
'application/cbor',
'application/vnd.ipld.dag-json',
'application/octet-stream',
'application/vnd.ipld.raw',
'application/vnd.ipfs.ipns-record',
'application/vnd.ipld.car'
],
[dagPbCode]: [
'application/octet-stream',
'application/json',
'application/vnd.ipld.dag-cbor',
'application/cbor',
'application/vnd.ipld.dag-json',
'application/vnd.ipld.raw',
'application/vnd.ipfs.ipns-record',
'application/vnd.ipld.car',
'application/x-tar'
],
[rawCode]: [
'application/octet-stream',
'application/vnd.ipld.raw',
'application/vnd.ipfs.ipns-record',
'application/vnd.ipld.car'
]
}

/**
* Selects an output mime-type based on the CID and a passed `Accept` header
*/
export function selectOutputType (cid: CID, accept?: string): string | undefined {
const cidMimeTypes = CID_TYPE_MAP[cid.code]

if (accept != null) {
return chooseMimeType(accept, cidMimeTypes)
}
}

function chooseMimeType (accept: string, validMimeTypes: string[]): string | undefined {
const requestedMimeTypes = accept
.split(',')
.map(s => {
const parts = s.trim().split(';')

return {
mimeType: `${parts[0]}`.trim(),
weight: parseQFactor(parts[1])
}
})
.sort((a, b) => {
if (a.weight === b.weight) {
return 0
}

if (a.weight > b.weight) {
return -1
}

return 1

Check warning on line 90 in packages/verified-fetch/src/utils/select-output-type.ts

View check run for this annotation

Codecov / codecov/patch

packages/verified-fetch/src/utils/select-output-type.ts#L89-L90

Added lines #L89 - L90 were not covered by tests
})
.map(s => s.mimeType)

for (const headerFormat of requestedMimeTypes) {
for (const mimeType of validMimeTypes) {
if (headerFormat.includes(mimeType)) {
return mimeType
}

if (headerFormat === '*/*') {
return mimeType
}

if (headerFormat.startsWith('*/') && mimeType.split('/')[1] === headerFormat.split('/')[1]) {
return mimeType
}

if (headerFormat.endsWith('/*') && mimeType.split('/')[0] === headerFormat.split('/')[0]) {
return mimeType
}
}
}
}

/**
* Parses q-factor weighting from the accept header to allow letting some mime
* types take precedence over others.
*
* If the q-factor for an acceptable mime representation is omitted it defaults
* to `1`.
*
* All specified values should be in the range 0-1.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept#q
*/
function parseQFactor (str?: string): number {
if (str != null) {
str = str.trim()
}

if (str == null || !str.startsWith('q=')) {
return 1
}

const factor = parseFloat(str.replace('q=', ''))

if (isNaN(factor)) {
return 0

Check warning on line 138 in packages/verified-fetch/src/utils/select-output-type.ts

View check run for this annotation

Codecov / codecov/patch

packages/verified-fetch/src/utils/select-output-type.ts#L138

Added line #L138 was not covered by tests
}

return factor
}

const FORMAT_TO_MIME_TYPE: Record<RequestFormatShorthand, string> = {
raw: 'application/vnd.ipld.raw',
car: 'application/vnd.ipld.car',
'dag-json': 'application/vnd.ipld.dag-json',
'dag-cbor': 'application/vnd.ipld.dag-cbor',
json: 'application/json',
cbor: 'application/cbor',
'ipns-record': 'application/vnd.ipfs.ipns-record',
tar: 'application/x-tar'
}

/**
* Converts a `format=...` query param to a mime type as would be found in the
* `Accept` header, if a valid mapping is available
*/
export function queryFormatToAcceptHeader (format?: RequestFormatShorthand): string | undefined {
if (format != null) {
return FORMAT_TO_MIME_TYPE[format]
}
}
Loading
Loading