diff --git a/src/client/failure/event-adapter.test.ts b/src/client/failure/event-adapter.test.ts index 288fa126..edab96b3 100644 --- a/src/client/failure/event-adapter.test.ts +++ b/src/client/failure/event-adapter.test.ts @@ -468,7 +468,13 @@ function spyOnCacheBoundProfileProvider(client: SuperfaceClientBase) { return Promise.resolve(thirdMockBoundProfileProvider); case 'invalid': - return Promise.reject(bindResponseError({ test: 'test' })); + return Promise.reject( + bindResponseError({ + statusCode: 400, + profileId: 'profileId', + title: 'test', + }) + ); default: throw 'unreachable'; @@ -1542,7 +1548,13 @@ describe.each([ const useCase = profile.getUseCase('Test'); const result = useCase.perform(undefined); - await expect(result).rejects.toThrow(bindResponseError({ test: 'test' })); + await expect(result).rejects.toThrow( + bindResponseError({ + statusCode: 400, + profileId: 'profileId', + title: 'test', + }) + ); //We send request twice expect((await endpoint.getSeenRequests()).length).toEqual(0); diff --git a/src/client/registry.test.ts b/src/client/registry.test.ts index 05060b37..476059ec 100644 --- a/src/client/registry.test.ts +++ b/src/client/registry.test.ts @@ -1,11 +1,17 @@ import { AstMetadata, MapDocumentNode, ProviderJson } from '@superfaceai/ast'; import { Config } from '../config'; -import { invalidProviderResponseError } from '../internal/errors.helpers'; +import { + bindResponseError, + invalidProviderResponseError, + unknownBindResponseError, + unknownProviderInfoError, +} from '../internal/errors.helpers'; import { assertIsRegistryProviderInfo, fetchBind, fetchMapSource, + fetchProviderInfo, } from './registry'; const request = jest.fn(); @@ -115,6 +121,110 @@ describe('registry', () => { }); }); + describe('when fetching provider info', () => { + const TEST_REGISTRY_URL = 'https://example.com/test-registry'; + const TEST_SDK_TOKEN = + 'sfs_bb064dd57c302911602dd097bc29bedaea6a021c25a66992d475ed959aa526c7_37bce8b5'; + + beforeEach(() => { + const config = Config.instance(); + config.superfaceApiUrl = TEST_REGISTRY_URL; + config.sdkAuthToken = TEST_SDK_TOKEN; + }); + + afterAll(() => { + Config.reloadFromEnv(); + }); + + it('fetches provider info', async () => { + const mockBody = { + definition: mockProviderJson, + }; + const mockResponse = { + statusCode: 200, + body: mockBody, + debug: { + request: { + url: 'test', + body: {}, + }, + }, + }; + + request.mockResolvedValue(mockResponse); + + await expect(fetchProviderInfo('test')).resolves.toEqual( + mockProviderJson + ); + + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith('/providers/test', { + method: 'GET', + headers: [`Authorization: SUPERFACE-SDK-TOKEN ${TEST_SDK_TOKEN}`], + baseUrl: TEST_REGISTRY_URL, + accept: 'application/json', + contentType: 'application/json', + body: undefined, + }); + }); + + it('throws on invalid body', async () => { + const mockBody = {}; + const mockResponse = { + statusCode: 200, + body: mockBody, + debug: { + request: { + url: 'test', + body: {}, + }, + }, + }; + + request.mockResolvedValue(mockResponse); + + await expect(fetchProviderInfo('test')).rejects.toEqual( + unknownProviderInfoError({ + message: 'Registry responded with invalid body', + body: mockBody, + provider: 'test', + statusCode: 200, + }) + ); + + expect(request).toHaveBeenCalledTimes(1); + }); + + it('throws on invalid provider json', async () => { + const mockBody = { + definition: {}, + }; + const mockResponse = { + statusCode: 200, + body: mockBody, + debug: { + request: { + url: 'test', + body: {}, + }, + }, + }; + + request.mockResolvedValue(mockResponse); + + await expect(fetchProviderInfo('test')).rejects.toEqual( + unknownProviderInfoError({ + message: 'Registry responded with invalid ProviderJson definition', + body: {}, + provider: 'test', + statusCode: 200, + }) + ); + + expect(request).toHaveBeenCalledTimes(1); + }); + }); + describe('when fetching bind', () => { const TEST_REGISTRY_URL = 'https://example.com/test-registry'; const TEST_SDK_TOKEN = @@ -256,19 +366,124 @@ describe('registry', () => { ).rejects.toThrow(invalidProviderResponseError({ path: ['input'] })); expect(request).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledWith('/registry/bind', { - method: 'POST', - baseUrl: TEST_REGISTRY_URL, - accept: 'application/json', - headers: [`Authorization: SUPERFACE-SDK-TOKEN ${MOCK_TOKEN}`], - contentType: 'application/json', - body: { - profile_id: 'test-profile-id', + }); + + it('throws error on invalid response body', async () => { + const mockBody = {}; + const mockResponse = { + statusCode: 200, + body: mockBody, + headers: { test: 'test' }, + debug: { + request: { + headers: { test: 'test' }, + url: 'test', + body: {}, + }, + }, + }; + + request.mockResolvedValue(mockResponse); + + await expect( + fetchBind({ + profileId: 'test-profile-id', provider: 'test-provider', - map_variant: 'test-map-variant', - map_revision: 'test-map-revision', + mapVariant: 'test-map-variant', + mapRevision: 'test-map-revision', + }) + ).rejects.toEqual( + unknownBindResponseError({ + profileId: 'test-profile-id', + provider: 'test-provider', + mapVariant: 'test-map-variant', + mapRevision: 'test-map-revision', + statusCode: 200, + body: mockBody, + }) + ); + + expect(request).toHaveBeenCalledTimes(1); + }); + + it('throws error on invalid status code and empty response body', async () => { + const mockBody = {}; + const mockResponse = { + statusCode: 400, + body: mockBody, + headers: { test: 'test' }, + debug: { + request: { + headers: { test: 'test' }, + url: 'test', + body: {}, + }, }, - }); + }; + + request.mockResolvedValue(mockResponse); + + await expect( + fetchBind({ + profileId: 'test-profile-id', + provider: 'test-provider', + mapVariant: 'test-map-variant', + mapRevision: 'test-map-revision', + }) + ).rejects.toEqual( + unknownBindResponseError({ + profileId: 'test-profile-id', + provider: 'test-provider', + mapVariant: 'test-map-variant', + mapRevision: 'test-map-revision', + statusCode: 400, + body: mockBody, + }) + ); + + expect(request).toHaveBeenCalledTimes(1); + }); + + it('throws error on invalid status code and response body with detail', async () => { + const mockBody = { + detail: 'Test', + title: 'Title', + }; + const mockResponse = { + statusCode: 400, + body: mockBody, + headers: { test: 'test' }, + debug: { + request: { + headers: { test: 'test' }, + url: 'test', + body: {}, + }, + }, + }; + + request.mockResolvedValue(mockResponse); + + await expect( + fetchBind({ + profileId: 'test-profile-id', + provider: 'test-provider', + mapVariant: 'test-map-variant', + mapRevision: 'test-map-revision', + }) + ).rejects.toEqual( + bindResponseError({ + profileId: 'test-profile-id', + provider: 'test-provider', + mapVariant: 'test-map-variant', + mapRevision: 'test-map-revision', + statusCode: 400, + detail: 'Test', + title: 'Title', + }) + ); + + expect(request).toHaveBeenCalledTimes(1); }); it('returns undefined on invalid map document', async () => { @@ -304,19 +519,6 @@ describe('registry', () => { }); expect(request).toHaveBeenCalledTimes(1); - expect(request).toHaveBeenCalledWith('/registry/bind', { - method: 'POST', - baseUrl: TEST_REGISTRY_URL, - accept: 'application/json', - headers: [`Authorization: SUPERFACE-SDK-TOKEN ${MOCK_TOKEN}`], - contentType: 'application/json', - body: { - profile_id: 'test-profile-id', - provider: 'test-provider', - map_variant: 'test-map-variant', - map_revision: 'test-map-revision', - }, - }); }); }); diff --git a/src/client/registry.ts b/src/client/registry.ts index ace7c27f..a847982d 100644 --- a/src/client/registry.ts +++ b/src/client/registry.ts @@ -12,8 +12,10 @@ import { UnexpectedError } from '../internal/errors'; import { bindResponseError, invalidProviderResponseError, + unknownBindResponseError, + unknownProviderInfoError, } from '../internal/errors.helpers'; -import { HttpClient } from '../internal/interpreter/http'; +import { HttpClient, HttpResponse } from '../internal/interpreter/http'; import { CrossFetch } from '../lib/fetch'; const registryDebug = createDebug('superface:registry'); @@ -71,15 +73,18 @@ export async function fetchProviderInfo( const sdkToken = Config.instance().sdkAuthToken; registryDebug(`Fetching provider ${providerName} from registry`); - const { body } = await http.request(`/providers/${providerName}`, { - method: 'GET', - headers: sdkToken - ? [`Authorization: SUPERFACE-SDK-TOKEN ${sdkToken}`] - : undefined, - baseUrl: Config.instance().superfaceApiUrl, - accept: 'application/json', - contentType: 'application/json', - }); + const { body, statusCode } = await http.request( + `/providers/${providerName}`, + { + method: 'GET', + headers: sdkToken + ? [`Authorization: SUPERFACE-SDK-TOKEN ${sdkToken}`] + : undefined, + baseUrl: Config.instance().superfaceApiUrl, + accept: 'application/json', + contentType: 'application/json', + } + ); function assertProperties( obj: unknown @@ -89,23 +94,52 @@ export async function fetchProviderInfo( obj === null || 'definition' in obj === false ) { - throw new UnexpectedError('Registry responded with invalid body'); + throw unknownProviderInfoError({ + message: 'Registry responded with invalid body', + body: obj, + provider: providerName, + statusCode, + }); } } assertProperties(body); if (!isProviderJson(body.definition)) { - throw new UnexpectedError('Registry responded with invalid body'); + throw unknownProviderInfoError({ + message: 'Registry responded with invalid ProviderJson definition', + body: body.definition, + provider: providerName, + statusCode, + }); } return body.definition; } -function parseBindResponse(input: unknown): { +function parseBindResponse( + request: { + profileId: string; + provider?: string; + mapVariant?: string; + mapRevision?: string; + }, + response: HttpResponse +): { provider: ProviderJson; mapAst?: MapDocumentNode; } { + function isErrorBody( + input: unknown + ): input is { detail: string; title: string } { + return ( + typeof input === 'object' && + input !== null && + 'detail' in input && + 'title' in input + ); + } + function assertProperties( obj: unknown ): asserts obj is { provider: unknown; map_ast: string } { @@ -115,22 +149,43 @@ function parseBindResponse(input: unknown): { 'provider' in obj === false || 'map_ast' in obj === false ) { - throw bindResponseError(input); + throw unknownBindResponseError({ + ...request, + statusCode: response.statusCode, + body: response.body, + }); } } - assertProperties(input); + if (response.statusCode !== 200) { + if (isErrorBody(response.body)) { + throw bindResponseError({ + ...request, + statusCode: response.statusCode, + title: response.body.title, + detail: response.body.detail, + }); + } + + throw unknownBindResponseError({ + ...request, + statusCode: response.statusCode, + body: response.body, + }); + } + + assertProperties(response.body); let mapAst: MapDocumentNode | undefined; try { - mapAst = assertMapDocumentNode(JSON.parse(input.map_ast)); + mapAst = assertMapDocumentNode(JSON.parse(response.body.map_ast)); } catch (error) { mapAst = undefined; } let provider; try { - provider = assertProviderJson(input.provider); + provider = assertProviderJson(response.body.provider); } catch (error) { throw invalidProviderResponseError(error); } @@ -154,7 +209,8 @@ export async function fetchBind(request: { const http = new HttpClient(fetchInstance); const sdkToken = Config.instance().sdkAuthToken; registryDebug('Binding SDK to registry'); - const { body } = await http.request('/registry/bind', { + + const fetchResponse = await http.request('/registry/bind', { method: 'POST', headers: sdkToken ? [`Authorization: SUPERFACE-SDK-TOKEN ${sdkToken}`] @@ -170,7 +226,7 @@ export async function fetchBind(request: { }, }); - return parseBindResponse(body); + return parseBindResponse(request, fetchResponse); } export async function fetchMapSource(mapId: string): Promise { diff --git a/src/internal/errors.helpers.ts b/src/internal/errors.helpers.ts index d50561ae..83c479f8 100644 --- a/src/internal/errors.helpers.ts +++ b/src/internal/errors.helpers.ts @@ -1,7 +1,7 @@ import { BackoffKind, SecurityValues } from '@superfaceai/ast'; -import { SDKBindError } from '.'; -import { SDKExecutionError } from './errors'; +import { Config } from '../config'; +import { SDKBindError, SDKExecutionError } from './errors'; export function ensureErrorSubclass(error: unknown): Error { if (typeof error === 'string') { @@ -362,18 +362,18 @@ export function providersDoNotMatchError( [] ); } -//Bind errors -export function bindResponseError(input: unknown): SDKExecutionError { - return new SDKBindError( - `Bind call responded with invalid body: ${JSON.stringify(input)}`, - [ - 'OneSdk expects response containing object', - 'Received object should contain property "provider" of type "ProviderJson"', - 'Received object should contain property "map_ast" of type "undefined" or "MapDocumentNode"', - ], - [] - ); -} +// //Bind errors +// export function bindResponseError(input: unknown): SDKExecutionError { +// return new SDKBindError( +// `Bind call responded with invalid body: ${JSON.stringify(input)}`, +// [ +// 'OneSdk expects response containing object', +// 'Received object should contain property "provider" of type "ProviderJson"', +// 'Received object should contain property "map_ast" of type "undefined" or "MapDocumentNode"', +// ], +// [] +// ); +// } export function digestHeaderNotFound( headerName: string, @@ -403,16 +403,6 @@ export function missingPartOfDigestHeader( ); } -export function invalidProviderResponseError( - input: unknown -): SDKExecutionError { - return new SDKBindError( - `Bind call responded with invalid provider body: ${JSON.stringify(input)}`, - ['Received provider should be of type "ProviderJson"'], - [] - ); -} - export function unexpectedDigestValue( valueName: string, value: string, @@ -428,3 +418,134 @@ export function unexpectedDigestValue( [] ); } +//Bind errors +export function invalidProviderResponseError( + input: unknown +): SDKExecutionError { + return new SDKBindError( + `Bind call responded with invalid provider body: ${JSON.stringify(input)}`, + ['Received provider should be of type "ProviderJson"'], + [] + ); +} + +export function bindResponseError({ + statusCode, + profileId, + provider, + title, + detail, + mapVariant, + mapRevision, +}: { + statusCode: number; + profileId: string; + provider?: string; + title?: string; + detail?: string; + mapVariant?: string; + mapRevision?: string; +}): SDKBindError { + const longLines = []; + + if (detail) { + longLines.push(detail); + } + + if (mapVariant) { + longLines.push(`Looking for map variant "${mapVariant}"`); + } + + if (mapRevision) { + longLines.push(`Looking for map revision "${mapRevision}"`); + } + + return new SDKBindError( + `Registry responded with status code ${statusCode}${ + title ? ` - ${title}.` : '.' + }`, + longLines, + [ + provider + ? `Check if profile "${profileId}" can be used with provider "${provider}"` + : `Check if profile "${profileId}" can be used with selected provider.`, + `If you are using remote profile you can check informations about profile at "${ + new URL(profileId, Config.instance().superfaceApiUrl).href + }"`, + `If you are trying to use remote profile check if profile "${profileId}" is published`, + 'If you are using local profile you can use local map and provider to bypass the binding', + ] + ); +} + +export function unknownBindResponseError({ + statusCode, + profileId, + body, + provider, + mapVariant, + mapRevision, +}: { + statusCode: number; + profileId: string; + body: unknown; + provider?: string; + mapVariant?: string; + mapRevision?: string; +}): SDKBindError { + const longLines = [ + provider + ? `Error occured when binding profile "${profileId}" with provider "${provider}"` + : `Error occured when binding profile "${profileId}" with selected provider`, + ]; + + if (mapVariant) { + longLines.push(`Looking for map variant "${mapVariant}"`); + } + + if (mapRevision) { + longLines.push(`Looking for map revision "${mapRevision}"`); + } + + return new SDKBindError( + `Registry responded with status code ${statusCode} and unexpected body ${String( + body + )}`, + longLines, + [ + provider + ? `Check if profile "${profileId}" can be used with provider "${provider}"` + : `Check if profile "${profileId}" can be used with selected provider`, + `If you are using remote profile you can check informations about profile at "${ + new URL(profileId, Config.instance().superfaceApiUrl).href + }"`, + `If you are trying to use remote profile check if profile "${profileId}" is published`, + 'If you are using local profile you can use local map and provider to bypass the binding', + ] + ); +} + +export function unknownProviderInfoError({ + message, + provider, + body, + statusCode, +}: { + message: string; + provider: string; + body: unknown; + statusCode: number; +}): SDKExecutionError { + const longLines = [ + message, + `Error occured when fetching info about provider "${provider}"`, + ]; + + return new SDKExecutionError( + `Registry responded with status code ${statusCode} and unexpected body ${String( + body + )}`, + longLines, + [`Check if provider "${provider}" is published`] + ); +} diff --git a/src/internal/interpreter/http/interfaces.ts b/src/internal/interpreter/http/interfaces.ts index e1a811d1..b2d66276 100644 --- a/src/internal/interpreter/http/interfaces.ts +++ b/src/internal/interpreter/http/interfaces.ts @@ -60,6 +60,7 @@ export type FetchInstance = { }; export const JSON_CONTENT = 'application/json'; +export const JSON_PROBLEM_CONTENT = 'application/problem+json'; export const URLENCODED_CONTENT = 'application/x-www-form-urlencoded'; export const FORMDATA_CONTENT = 'multipart/form-data'; export const BINARY_CONTENT_TYPES = [ diff --git a/src/lib/fetch.ts b/src/lib/fetch.ts index 9b77118a..ae0a31c5 100644 --- a/src/lib/fetch.ts +++ b/src/lib/fetch.ts @@ -14,6 +14,7 @@ import { isStringBody, isUrlSearchParamsBody, JSON_CONTENT, + JSON_PROBLEM_CONTENT, } from '../internal/interpreter/http/interfaces'; import { eventInterceptor, @@ -66,7 +67,8 @@ export class CrossFetch implements FetchInstance, Interceptable { if ( headers['content-type'] && - headers['content-type'].includes(JSON_CONTENT) //|| + (headers['content-type'].includes(JSON_CONTENT) || + headers['content-type'].includes(JSON_PROBLEM_CONTENT)) //|| //TODO: update this when we have security handlers preparing whole requests // parameters.headers?.['accept']?.includes(JSON_CONTENT) ) {