From ff7220c7d80ad6fe4997eaa98eca6eb6f93bbd95 Mon Sep 17 00:00:00 2001 From: Irakli Gozalishvili Date: Wed, 29 Mar 2023 08:43:15 -0700 Subject: [PATCH] cover new transport code --- packages/transport/src/cbor.js | 2 + packages/transport/src/codec.js | 51 ++++-- packages/transport/test/codec.spec.js | 247 ++++++++++++++++++++++++++ 3 files changed, 286 insertions(+), 14 deletions(-) create mode 100644 packages/transport/test/codec.spec.js diff --git a/packages/transport/src/cbor.js b/packages/transport/src/cbor.js index a11d6147..40cdf273 100644 --- a/packages/transport/src/cbor.js +++ b/packages/transport/src/cbor.js @@ -13,6 +13,7 @@ export const codec = CBOR * @template I * @param {I} result * @returns {API.HTTPResponse} + * @deprecated */ export const encode = result => { return { @@ -27,6 +28,7 @@ export const encode = result => { * @template I * @param {API.HTTPResponse} request * @returns {Promise} + * @deprecated */ export const decode = async ({ headers, body }) => { const contentType = headers['content-type'] || headers['Content-Type'] diff --git a/packages/transport/src/codec.js b/packages/transport/src/codec.js index 9df6f1a0..0657d8c8 100644 --- a/packages/transport/src/codec.js +++ b/packages/transport/src/codec.js @@ -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)), }, }, } @@ -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') + } } } @@ -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') @@ -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 */ @@ -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(', ') diff --git a/packages/transport/test/codec.spec.js b/packages/transport/test/codec.spec.js new file mode 100644 index 00000000..89d37cc7 --- /dev/null +++ b/packages/transport/test/codec.spec.js @@ -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()