diff --git a/package-lock.json b/package-lock.json index 36519c34de..d7f3d2837b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "vscode-docker", - "version": "1.13.1-alpha", + "version": "1.15.0-alpha", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "@azure/arm-appservice": "^6.1.0", diff --git a/src/tree/registries/dockerV2/DockerV2RepositoryTreeItem.ts b/src/tree/registries/dockerV2/DockerV2RepositoryTreeItem.ts index d9f06f1730..6f30e294be 100644 --- a/src/tree/registries/dockerV2/DockerV2RepositoryTreeItem.ts +++ b/src/tree/registries/dockerV2/DockerV2RepositoryTreeItem.ts @@ -5,7 +5,7 @@ import { AzExtTreeItem, IActionContext } from "vscode-azureextensionui"; import { PAGE_SIZE } from "../../../constants"; -import { IOAuthContext, RequestLike } from "../../../utils/httpRequest"; +import { ErrorHandling, HttpErrorResponse, HttpStatusCode, IOAuthContext, RequestLike } from "../../../utils/httpRequest"; import { getNextLinkFromHeaders, registryRequest } from "../../../utils/registryRequestUtils"; import { IAuthProvider } from "../auth/IAuthProvider"; import { ICachedRegistryProvider } from "../ICachedRegistryProvider"; @@ -29,7 +29,16 @@ export class DockerV2RepositoryTreeItem extends RemoteRepositoryTreeItemBase imp } const url = this._nextLink || `v2/${this.repoName}/tags/list?n=${PAGE_SIZE}`; - const response = await registryRequest(this, 'GET', url); + const response = await registryRequest(this, 'GET', url, undefined, ErrorHandling.ReturnErrorResponse); + if (response.status === HttpStatusCode.NotFound) { + // Some registries return 404 when all tags have been removed and the repository becomes effectively unavailable. + void this.deleteTreeItem(context); + return []; + } + else if (!response.ok) { + throw new HttpErrorResponse(response); + } + this._nextLink = getNextLinkFromHeaders(response); return await this.createTreeItemsWithErrorHandling( response.body.tags, diff --git a/src/utils/httpRequest.ts b/src/utils/httpRequest.ts index 4a6b6b34a7..4c47ad0c85 100644 --- a/src/utils/httpRequest.ts +++ b/src/utils/httpRequest.ts @@ -8,7 +8,17 @@ import { default as fetch, Request, RequestInit, Response } from 'node-fetch'; import { URL, URLSearchParams } from 'url'; import { localize } from '../localize'; -export async function httpRequest(url: string, options?: RequestOptionsLike, signRequest?: (request: RequestLike) => Promise): Promise> { +export const enum ErrorHandling { + ThrowOnError, + ReturnErrorResponse +} + +export async function httpRequest( + url: string, + options?: RequestOptionsLike, + signRequest?: (request: RequestLike) => Promise, + errorHandling: ErrorHandling = ErrorHandling.ThrowOnError +): Promise> { const requestOptions: RequestInit = options; if (options.form) { // URLSearchParams is a silly way to say "it's form data" @@ -23,18 +33,37 @@ export async function httpRequest(url: string, options?: RequestOptionsLike, const response = await fetch(request); - if (response.status >= 200 && response.status < 300) { - return new HttpResponse(response); + if (errorHandling === ErrorHandling.ReturnErrorResponse || response.ok) { + return new HttpResponse(response, url); } else { throw new HttpErrorResponse(response); } } -export class HttpResponse { +export class HttpResponse implements ResponseLike { private bodyPromise: Promise | undefined; - private normalizedHeaders: { [key: string]: string } | undefined; + public readonly headers: HeadersLike; + public readonly status: number; + public readonly statusText: string; + public readonly ok: boolean; + + public constructor(private readonly innerResponse: Response, public readonly url: string) { + // Unfortunately Typescript will not consider a getter accessor when checking whether a class implements an interface. + // So we are forced to use readonly members to implement ResponseLike interface. + const headerStore: { [key: string]: string } = {}; + for (const key of this.innerResponse.headers.keys()) { + headerStore[key] = this.innerResponse.headers.get(key); + } - public constructor(private readonly innerResponse: Response) { } + this.headers = { + get: (key: string) => headerStore[key], + set: (key: string, value: string) => { headerStore[key] = value; } + }; + + this.status = this.innerResponse.status; + this.statusText = this.innerResponse.statusText; + this.ok = this.innerResponse.ok; + } public async json(): Promise { if (!this.bodyPromise) { @@ -44,17 +73,6 @@ export class HttpResponse { return this.bodyPromise; } - - public get headers(): { [key: string]: string } { - if (!this.normalizedHeaders) { - this.normalizedHeaders = {}; - for (const key of this.innerResponse.headers.keys()) { - this.normalizedHeaders[key] = this.innerResponse.headers.get(key); - } - } - - return this.normalizedHeaders; - } } export class HttpErrorResponse extends Error { @@ -70,6 +88,12 @@ export class HttpErrorResponse extends Error { type RequestMethod = 'HEAD' | 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH'; +// Contains only status codes that we handle explicitly in the extension code +export const enum HttpStatusCode { + Unauthorized = 401, + NotFound = 404 +} + export interface RequestOptionsLike { headers?: { [key: string]: string }; method?: RequestMethod; // This is an enum type because it enforces the above valid options on callers (which do not directly use node-fetch's Request object) @@ -92,6 +116,7 @@ export interface ResponseLike { url: string; status: number; statusText: string; + ok: boolean; } export async function streamToFile(downloadUrl: string, fileName: string): Promise { @@ -131,7 +156,7 @@ const serviceRegExp = /service="([^"]+)"/i; const scopeRegExp = /scope="([^"]+)"/i; export function getWwwAuthenticateContext(error: HttpErrorResponse): IOAuthContext | undefined { - if (error.response?.status === 401) { + if (error.response?.status === HttpStatusCode.Unauthorized) { const wwwAuthHeader: string | undefined = error.response?.headers?.get('www-authenticate') as string; const realmMatch = wwwAuthHeader?.match(realmRegExp); diff --git a/src/utils/registryRequestUtils.ts b/src/utils/registryRequestUtils.ts index 0ca9c1265c..d576196785 100644 --- a/src/utils/registryRequestUtils.ts +++ b/src/utils/registryRequestUtils.ts @@ -5,10 +5,10 @@ import { URL } from "url"; import { ociClientId } from "../constants"; -import { httpRequest, RequestLike } from './httpRequest'; +import { ErrorHandling, httpRequest, RequestLike, ResponseLike } from './httpRequest'; -export function getNextLinkFromHeaders(response: IResponse): string | undefined { - const linkHeader: string | undefined = response.headers.link as string; +export function getNextLinkFromHeaders(response: IRegistryRequestResponse): string | undefined { + const linkHeader: string | undefined = response.headers.get('link') as string; if (linkHeader) { const match = linkHeader.match(/<(.*)>; rel="next"/i); return match ? match[1] : undefined; @@ -17,7 +17,13 @@ export function getNextLinkFromHeaders(response: IResponse): string | u } } -export async function registryRequest(node: IRegistryAuthTreeItem | IRepositoryAuthTreeItem, method: 'GET' | 'DELETE' | 'POST', url: string, customOptions?: RequestInit): Promise> { +export async function registryRequest( + node: IRegistryAuthTreeItem | IRepositoryAuthTreeItem, + method: 'GET' | 'DELETE' | 'POST', + url: string, + customOptions?: RequestInit, + errorHandling: ErrorHandling = ErrorHandling.ThrowOnError +): Promise> { const options = { method: method, headers: { @@ -39,17 +45,16 @@ export async function registryRequest(node: IRegistryAuthTreeItem | IReposito } else { return (node).parent?.signRequest(request); } - }); + }, errorHandling); return { body: method !== 'DELETE' ? await response.json() : undefined, - headers: response.headers, + ...response }; } -interface IResponse { - body: T, - headers: { [key: string]: string | string[] }, +export interface IRegistryRequestResponse extends ResponseLike { + body: T } export interface IRegistryAuthTreeItem {