Skip to content

Commit

Permalink
cover new transport code
Browse files Browse the repository at this point in the history
  • Loading branch information
Gozala committed Mar 29, 2023
1 parent cd26404 commit ff7220c
Show file tree
Hide file tree
Showing 3 changed files with 286 additions and 14 deletions.
2 changes: 2 additions & 0 deletions packages/transport/src/cbor.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const codec = CBOR
* @template I
* @param {I} result
* @returns {API.HTTPResponse<I>}
* @deprecated
*/
export const encode = result => {
return {
Expand All @@ -27,6 +28,7 @@ export const encode = result => {
* @template I
* @param {API.HTTPResponse<I>} request
* @returns {Promise<I>}
* @deprecated
*/
export const decode = async ({ headers, body }) => {
const contentType = headers['content-type'] || headers['Content-Type']
Expand Down
51 changes: 37 additions & 14 deletions packages/transport/src/codec.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class Inbound {
status: 406,
message: `The requested resource cannot be served in the requested content type. Please specify a supported content type using the Accept header.`,
headers: {
accept: Object.keys(this.encoders).join(', '),
accept: formatAcceptHeader(Object.values(this.encoders)),
},
},
}
Expand All @@ -64,13 +64,22 @@ class Inbound {
*/
constructor({ decoders = {}, encoders = {} }) {
this.decoders = decoders

if (Object.keys(decoders).length === 0) {
throw new Error('At least one decoder MUST be provided')
}

// We sort the encoders by preference, so that we can pick the most
// preferred one when client accepts multiple content types.
this.encoders = Object.entries(encoders)
.map(([contentType, encoder]) => {
return { ...parseMediaType(contentType), encoder }
.map(([mediaType, encoder]) => {
return { ...parseMediaType(mediaType), encoder }
})
.sort((a, b) => b.preference - a.preference)

if (this.encoders.length === 0) {
throw new Error('At least one encoder MUST be provided')
}
}
}

Expand All @@ -94,20 +103,19 @@ class Outbound {
constructor({ decoders = {}, encoders = {} }) {
this.decoders = decoders

if (Object.keys(decoders).length === 0) {
throw new Error('At least one decoder MUST be provided')
}

// We sort the encoders by preference, so that we can pick the most
// preferred one when client accepts multiple content types.
this.encoders = Object.entries(encoders)
.map(([contentType, encoder]) => {
return { ...parseMediaType(contentType), encoder }
.map(([mediaType, encoder]) => {
return { ...parseMediaType(mediaType), encoder }
})
.sort((a, b) => b.preference - a.preference)

this.acceptType = this.encoders
.map(
({ category, type, preference }) =>
`${category}/${type}${preference ? `;q=${preference}` : ''}`
)
.join(', ')
this.acceptType = formatAcceptHeader(this.encoders)

if (this.encoders.length === 0) {
throw new Error('At least one encoder MUST be provided')
Expand Down Expand Up @@ -156,22 +164,31 @@ class Outbound {
}

/**
*
* @typedef {{ category: string, type: string, preference: number }} Media
* @param {string} source
* @returns {{ category: string, type: string, preference: number }}
* @returns {Media}
*/
export const parseMediaType = source => {
const [mediaType = '*/*', mediaRange = 'q=0'] = source.trim().split(';')
const [mediaType = '*/*', mediaRange = ''] = source.trim().split(';')
const [category = '*', type = '*'] = mediaType.split('/')
const params = new URLSearchParams(mediaRange)
const preference = parseFloat(params.get('q') || '0')
return {
category,
type,
/* c8 ignore next */
preference: isNaN(preference) ? 0 : preference,
}
}

/**
* @param {Media} media
*/
export const formatMediaType = ({ category, type, preference }) =>
/** @type {MediaType} */ (
`${category}/${type}${preference ? `;q=${preference}` : ''}`
)

/**
* @param {string} source
*/
Expand All @@ -180,3 +197,9 @@ export const parseAcceptHeader = source =>
.split(',')
.map(parseMediaType)
.sort((a, b) => b.preference - a.preference)

/**
* @param {Media[]} source
*/
export const formatAcceptHeader = source =>
source.map(formatMediaType).join(', ')
247 changes: 247 additions & 0 deletions packages/transport/test/codec.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import { test, assert } from './test.js'
import * as CAR from '../src/car.js'
import * as Transport from '../src/lib.js'
import { alice, bob, mallory, service } from './fixtures.js'
import { invoke, API, delegate, parseLink, Receipt } from '@ucanto/core'
import { CarReader } from '@ipld/car/reader'

test('unsupported inbound content-type', async () => {
const accept = CAR.inbound.accept({
headers: { 'content-type': 'application/json' },
body: new Uint8Array(),
})

assert.deepEqual(accept, {
error: {
status: 415,
message: `The server cannot process the request because the payload format is not supported. Please check the content-type header and try again with a supported media type.`,
headers: {
accept: `application/car`,
},
},
})
})

test('unsupported inbound accept type', async () => {
const accept = CAR.inbound.accept({
headers: { 'content-type': 'application/car', accept: 'application/cbor' },
body: new Uint8Array(),
})

assert.deepEqual(accept, {
error: {
status: 406,
message: `The requested resource cannot be served in the requested content type. Please specify a supported content type using the Accept header.`,
headers: {
accept: `application/car`,
},
},
})
})

test(`requires encoders / decoders`, async () => {
assert.throws(
() =>
Transport.outbound({ encoders: { '*/*': CAR.request }, decoders: {} }),
/At least one decoder MUST be provided/
)

assert.throws(
() =>
Transport.outbound({ encoders: {}, decoders: { '*/*': CAR.response } }),
/At least one encoder MUST be provided/
)

assert.throws(
() =>
Transport.inbound({ encoders: { '*/*': CAR.response }, decoders: {} }),
/At least one decoder MUST be provided/
)

assert.throws(
() => Transport.inbound({ encoders: {}, decoders: { '*/*': CAR.request } }),
/At least one encoder MUST be provided/
)
})

test('outbound encode', async () => {
const cid = parseLink(
'bafyreiaxnmoptsqiehdff2blpptvdbenxcz6xgrbojw5em36xovn2xea4y'
)
const expiration = 1654298135

const request = await CAR.outbound.encode([
invoke({
issuer: alice,
audience: bob,
capability: {
can: 'store/add',
with: alice.did(),
},
expiration,
proofs: [],
}),
])

assert.deepEqual(request.headers, {
'content-type': 'application/car',
accept: 'application/car',
})
const reader = await CarReader.fromBytes(request.body)

assert.deepEqual(
await reader.getRoots(),
// @ts-expect-error - CAR refers to old CID
[cid]
)

const expect = await delegate({
issuer: alice,
audience: bob,
capabilities: [
{
can: 'store/add',
with: alice.did(),
},
],
expiration,
proofs: [],
})

assert.deepEqual(
[expect],
await CAR.inbound.accept(request).ok?.decoder.decode(request),
'roundtrips'
)
})

test('outbound decode', async () => {
const { success, failure } = await buildPayload()

const response = await CAR.response.encode([success, failure])
const receipts = await CAR.outbound.decode(response)

assert.deepEqual(
receipts.map($ => $.root),
[success.root, failure.root]
)
})

test('inbound supports Content-Type header', async () => {
const accept = await CAR.inbound.accept({
headers: { 'Content-Type': 'application/car' },
body: new Uint8Array(),
})

assert.equal(accept.ok != null, true)
})

test('outbound supports Content-Type header', async () => {
const { success } = await buildPayload()
const { body } = await CAR.response.encode([success])

const receipts = await CAR.outbound.decode({
headers: { 'Content-Type': 'application/car' },
body,
})

assert.deepEqual(receipts[0].root, success.root)
})

test('inbound encode preference', async () => {
const codec = Transport.inbound({
encoders: {
'application/car': CAR.response,
},
decoders: {
'application/car': CAR.request,
},
})

const accept = await codec.accept({
headers: {
'content-type': 'application/car',
accept: 'application/car',
},
body: new Uint8Array(),
})

assert.equal(accept.ok != null, true)
})

test('unsupported response content-type', async () => {
const { success } = await buildPayload()

const response = await CAR.response.encode([success])

const badContentType = await wait(() =>
CAR.outbound.decode({
...response,
headers: { ...response.headers, 'content-type': 'application/json' },
})
).catch(error => error)

assert.match(
String(badContentType),
/Can not decode response with content-type 'application\/json'/
)

const badStatus = await wait(() =>
CAR.outbound.decode({
...response,
headers: { ...response.headers, 'content-type': 'text/plain' },
status: 415,
body: new TextEncoder().encode('Whatever server sets'),
})
).catch(error => error)

assert.match(String(badStatus), /Whatever server sets/)
assert.equal(Object(badStatus).status, 415)
})

test('format media type', async () => {
assert.equal(
Transport.formatMediaType({
category: 'application',
type: 'car',
preference: 1,
}),
'application/car;q=1'
)
})

const buildPayload = async () => {
const expiration = 1654298135
const ran = await delegate({
issuer: alice,
audience: bob,
capabilities: [
{
can: 'store/add',
with: alice.did(),
},
],
expiration,
proofs: [],
})

const success = await Receipt.issue({
ran: ran.cid,
issuer: bob,
result: { ok: { hello: 'message' } },
})

const failure = await Receipt.issue({
ran: ran.cid,
issuer: bob,
result: { error: { message: 'Boom' } },
})

return { ran, success, failure }
}

/**
* @template {(...args:any[]) => any} Fn
* @param {Fn} fn
*/
const wait = async fn => fn()

0 comments on commit ff7220c

Please sign in to comment.