diff --git a/packages/nextclade-web/package.json b/packages/nextclade-web/package.json index 2fe000aa2..42ea260f5 100644 --- a/packages/nextclade-web/package.json +++ b/packages/nextclade-web/package.json @@ -87,6 +87,8 @@ }, "dependencies": { "@floating-ui/react": "0.26.1", + "@hapi/accept": "6.0.3", + "@hapi/content": "6.0.0", "animate.css": "4.1.1", "auspice": "2.53.0", "autoprefixer": "10.4.5", @@ -200,6 +202,7 @@ "@types/friendly-errors-webpack-plugin": "0.1.4", "@types/fs-extra": "9.0.13", "@types/glob": "7.2.0", + "@types/hapi__content": "6.0.3", "@types/history": "4.7.11", "@types/intercept-stdout": "0.1.0", "@types/jest": "27.4.1", diff --git a/packages/nextclade-web/src/io/axiosFetch.ts b/packages/nextclade-web/src/io/axiosFetch.ts index 11376e3f4..4e12a6022 100644 --- a/packages/nextclade-web/src/io/axiosFetch.ts +++ b/packages/nextclade-web/src/io/axiosFetch.ts @@ -1,8 +1,15 @@ import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios' -import { isNil } from 'lodash' +import { isNil, isString } from 'lodash' +import { mediaTypes as parseAcceptHeader } from '@hapi/accept' +import { ContentType, type as parseContentTypeHeader } from '@hapi/content' import { ErrorFatal } from 'src/helpers/ErrorFatal' import { sanitizeError } from 'src/helpers/sanitizeError' +export interface RequestConfig extends AxiosRequestConfig { + // Check that MIME type of response's Content-Type header is compatible with at least one of MIME types in the request's Accept header + strictAccept?: boolean +} + export class HttpRequestError extends Error { public readonly url?: string public readonly status?: number | string @@ -36,10 +43,27 @@ export function validateUrl(url?: string): string { return url } -export async function axiosFetch( - url_: string | undefined, - options?: AxiosRequestConfig, -): Promise { +export interface CheckMimeTypesResult { + isCompatible: boolean + acceptTypes?: string[] + contentType?: ContentType + acceptHeader?: string | number | boolean + contentTypeHeader?: string +} + +export function checkMimeTypes(req?: RequestConfig, res?: AxiosResponse): CheckMimeTypesResult { + const acceptHeader = req?.headers?.Accept + const contentTypeHeader = res?.headers['content-type'] + if (isString(acceptHeader) && isString(contentTypeHeader)) { + const acceptTypes = parseAcceptHeader(acceptHeader) + const contentType = parseContentTypeHeader(contentTypeHeader) + const isCompatible = acceptTypes.includes(contentType.mime) + return { isCompatible, acceptTypes, contentType, acceptHeader, contentTypeHeader } + } + return { isCompatible: false, acceptHeader, contentTypeHeader } +} + +export async function axiosFetch(url_: string | undefined, options?: RequestConfig): Promise { const url = validateUrl(url_) let res @@ -53,6 +77,17 @@ export async function axiosFetch( throw new Error(`Unable to fetch: request to URL "${url}" resulted in no data`) } + if (options?.strictAccept) { + const mime = checkMimeTypes(options, res) + if (!mime.isCompatible) { + const accept = mime.acceptHeader ?? '' + const contentType = mime.contentTypeHeader ?? '' + throw new Error( + `Unable to fetch: request to URL "${url}" resulted in incompatible MIME type: Content-Type was "${contentType}", while Accept was "${accept}"`, + ) + } + } + return res.data as TData } @@ -65,7 +100,7 @@ export async function axiosFetchMaybe(url?: string): Promise export async function axiosFetchOrUndefined( url: string | undefined, - options?: AxiosRequestConfig, + options?: RequestConfig, ): Promise { try { return await axiosFetch(url, options) @@ -77,7 +112,7 @@ export async function axiosFetchOrUndefined( /** * This version skips any transforms (such as JSON parsing) and returns plain string */ -export async function axiosFetchRaw(url: string | undefined, options?: AxiosRequestConfig): Promise { +export async function axiosFetchRaw(url: string | undefined, options?: RequestConfig): Promise { return axiosFetch(url, { ...options, transformResponse: [] }) } @@ -88,7 +123,7 @@ export async function axiosFetchRawMaybe(url?: string): Promise { +export async function axiosHead(url: string | undefined, options?: RequestConfig): Promise { if (isNil(url)) { throw new ErrorFatal(`Attempted to fetch from an invalid URL: '${url}'`) } @@ -102,7 +137,7 @@ export async function axiosHead(url: string | undefined, options?: AxiosRequestC export async function axiosHeadOrUndefined( url: string | undefined, - options?: AxiosRequestConfig, + options?: RequestConfig, ): Promise { try { return await axiosHead(url, options) diff --git a/packages/nextclade-web/src/io/fetchSingleDatasetAuspice.ts b/packages/nextclade-web/src/io/fetchSingleDatasetAuspice.ts index 70f6e7b9e..a9967f2d7 100644 --- a/packages/nextclade-web/src/io/fetchSingleDatasetAuspice.ts +++ b/packages/nextclade-web/src/io/fetchSingleDatasetAuspice.ts @@ -1,13 +1,27 @@ +import { isEmpty } from 'lodash' import { attrStrMaybe, AuspiceTree, Dataset } from 'src/types' import { removeTrailingSlash } from 'src/io/url' -import { axiosFetch } from 'src/io/axiosFetch' +import { axiosFetch, axiosFetchOrUndefined } from 'src/io/axiosFetch' export async function fetchSingleDatasetAuspice(datasetJsonUrl_: string) { const datasetJsonUrl = removeTrailingSlash(datasetJsonUrl_) const auspiceJson = await axiosFetch(datasetJsonUrl, { - headers: { Accept: 'application/json, text/plain, */*' }, + headers: { + Accept: 'application/vnd.nextstrain.dataset.main+json;q=1, application/json;q=0.9, text/plain;q=0.8, */*;q=0.1', + }, }) + + if (isEmpty(auspiceJson.root_sequence)) { + const sidecar = await axiosFetchOrUndefined>(datasetJsonUrl, { + headers: { Accept: 'application/vnd.nextstrain.dataset.root-sequence+json' }, + strictAccept: true, + }) + if (!isEmpty(sidecar)) { + auspiceJson.root_sequence = sidecar + } + } + const pathogen = auspiceJson.meta.extensions?.nextclade?.pathogen const name = diff --git a/packages/nextclade-web/src/styles/global.scss b/packages/nextclade-web/src/styles/global.scss index 82abe716a..da3833ea2 100644 --- a/packages/nextclade-web/src/styles/global.scss +++ b/packages/nextclade-web/src/styles/global.scss @@ -11,7 +11,9 @@ @import './components/Results'; -html, body, #__next { +html, +body, +#__next { overflow: hidden; height: 100%; width: 100%; diff --git a/packages/nextclade-web/yarn.lock b/packages/nextclade-web/yarn.lock index 9da670f27..4a331d9be 100644 --- a/packages/nextclade-web/yarn.lock +++ b/packages/nextclade-web/yarn.lock @@ -2365,6 +2365,33 @@ protobufjs "^7.0.0" yargs "^16.2.0" +"@hapi/accept@6.0.3": + version "6.0.3" + resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-6.0.3.tgz#eef0800a4f89cd969da8e5d0311dc877c37279ab" + integrity sha512-p72f9k56EuF0n3MwlBNThyVE5PXX40g+aQh+C/xbKrfzahM2Oispv3AXmOIU51t3j77zay1qrX7IIziZXspMlw== + dependencies: + "@hapi/boom" "^10.0.1" + "@hapi/hoek" "^11.0.2" + +"@hapi/boom@^10.0.0", "@hapi/boom@^10.0.1": + version "10.0.1" + resolved "https://registry.yarnpkg.com/@hapi/boom/-/boom-10.0.1.tgz#ebb14688275ae150aa6af788dbe482e6a6062685" + integrity sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA== + dependencies: + "@hapi/hoek" "^11.0.2" + +"@hapi/content@6.0.0": + version "6.0.0" + resolved "https://registry.yarnpkg.com/@hapi/content/-/content-6.0.0.tgz#2427af3bac8a2f743512fce2a70cbdc365af29df" + integrity sha512-CEhs7j+H0iQffKfe5Htdak5LBOz/Qc8TRh51cF+BFv0qnuph3Em4pjGVzJMkI2gfTDdlJKWJISGWS1rK34POGA== + dependencies: + "@hapi/boom" "^10.0.0" + +"@hapi/hoek@^11.0.2": + version "11.0.4" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-11.0.4.tgz#42a7f244fd3dd777792bfb74b8c6340ae9182f37" + integrity sha512-PnsP5d4q7289pS2T2EgGz147BFJ2Jpb4yrEdkpz2IhgEUzos1S7HTl7ezWh1yfYzYlj89KzLdCRkqsP6SIryeQ== + "@hot-loader/react-dom@^16.13.0": version "16.14.0" resolved "https://registry.yarnpkg.com/@hot-loader/react-dom/-/react-dom-16.14.0.tgz#3cfc64e40bb78fa623e59b582b8f09dcdaad648a" @@ -3434,6 +3461,13 @@ dependencies: "@types/node" "*" +"@types/hapi__content@6.0.3": + version "6.0.3" + resolved "https://registry.yarnpkg.com/@types/hapi__content/-/hapi__content-6.0.3.tgz#4e3820d8d07ae90de955263622e9c56ee177bd01" + integrity sha512-J0TOzZIy99b9G2ujGXpC7369wohXLCLwNfxEFN+X1w0oYOEhQ5+ViKweCZ919fGVp9U40QAjUc/LD1h1CVFCpQ== + dependencies: + "@types/node" "*" + "@types/hast@^2.0.0": version "2.3.4" resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.4.tgz#8aa5ef92c117d20d974a82bdfb6a648b08c0bafc"