Skip to content

Commit

Permalink
Merge pull request #325 from mittwald/chore/error-messages
Browse files Browse the repository at this point in the history
Add specialized error formats when IDs for unexpected resource types are supplied
  • Loading branch information
martin-helmich authored Apr 22, 2024
2 parents 0ca1754 + 8ad7fdc commit d8076f7
Show file tree
Hide file tree
Showing 16 changed files with 411 additions and 198 deletions.
4 changes: 4 additions & 0 deletions src/lib/app/flags.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export const {
} = makeFlagSet("installation", "i", {
displayName: "app installation",
normalize: normalizeAppInstallationId,
expectedShortIDFormat: {
pattern: /^a-.*/,
display: "a-XXXXXX",
},
});
export type AvailableFlagName = keyof AvailableFlags;

Expand Down
20 changes: 19 additions & 1 deletion src/lib/context_flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import {
import { MittwaldAPIV2Client } from "@mittwald/api-client";
import { AlphabetLowercase } from "@oclif/core/lib/interfaces/index.js";
import { Context, ContextKey, ContextNames } from "./context.js";
import UnexpectedShortIDPassedError from "./error/UnexpectedShortIDPassedError.js";
import { isUuid } from "../normalize_id.js";
import { articleForWord } from "./language.js";

export type ContextFlags<
N extends ContextNames,
Expand Down Expand Up @@ -66,6 +69,10 @@ export type FlagSet<TName extends ContextNames> = {
export type FlagSetOptions = {
normalize: NormalizeFn;
displayName: string;
expectedShortIDFormat: {
pattern: RegExp;
display: string;
};
};

export type NormalizeFn = (
Expand Down Expand Up @@ -118,7 +125,7 @@ export function makeFlagSet<TName extends ContextNames>(
opts: Partial<FlagSetOptions> = {},
): FlagSet<TName> {
const { displayName = name, normalize = (_, id) => id } = opts;
const article = displayName.match(/^[aeiou]/i) ? "an" : "a";
const article = articleForWord(displayName);

const flagName: ContextKey<TName> = `${name}-id`;
const flags = {
Expand Down Expand Up @@ -152,6 +159,16 @@ export function makeFlagSet<TName extends ContextNames>(
return undefined;
};

let idInputSanityCheck: (id: string) => void = (): void => {};
if (opts.expectedShortIDFormat != null) {
const format = opts.expectedShortIDFormat;
idInputSanityCheck = (id: string): void => {
if (!isUuid(id) && !format.pattern.test(id)) {
throw new UnexpectedShortIDPassedError(displayName, format.display);
}
};
}

const withId = async (
apiClient: MittwaldAPIV2Client,
commandType: CommandType<TName> | "flag" | "arg",
Expand All @@ -161,6 +178,7 @@ export function makeFlagSet<TName extends ContextNames>(
): Promise<string> => {
const idInput = idFromArgsOrFlag(flags, args);
if (idInput) {
idInputSanityCheck(idInput);
return normalize(apiClient, idInput);
}

Expand Down
15 changes: 15 additions & 0 deletions src/lib/error/UnexpectedShortIDPassedError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { articleForWord } from "../language.js";

export default class UnexpectedShortIDPassedError extends Error {
public readonly resourceName: string;
public readonly format: string;

public constructor(name: string, format: string) {
super(
`This command expects ${articleForWord(name)} ${name}, which is typically formatted as ${format}. It looks like you passed a short ID for another type of resource, instead.`,
);

this.resourceName = name;
this.format = format;
}
}
12 changes: 12 additions & 0 deletions src/lib/language.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Helper function to determine the grammatically correct article for a word.
*
* This is an approximation and ignores common exceptions, like unsounded "h"s.
* However, for our purposes, it should be sufficient.
*
* @param word
* @returns Returns "an" if the word starts with a vowel, "a" otherwise
*/
export function articleForWord(word: string): "an" | "a" {
return /^[aeiou]/i.test(word) ? "an" : "a";
}
11 changes: 9 additions & 2 deletions src/lib/project/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,19 @@ import { AlphabetLowercase } from "@oclif/core/lib/interfaces/index.js";
import { Args, Config, Flags } from "@oclif/core";
import { ArgOutput, FlagOutput } from "@oclif/core/lib/interfaces/parser.js";
import { MittwaldAPIV2Client } from "@mittwald/api-client";
import { articleForWord } from "../language.js";

export const {
flags: projectFlags,
args: projectArgs,
withId: withProjectId,
} = makeFlagSet("project", "p", { normalize: normalizeProjectId });
} = makeFlagSet("project", "p", {
normalize: normalizeProjectId,
expectedShortIDFormat: {
pattern: /^p-.*/,
display: "p-XXXXXX",
},
});

export type SubNormalizeFn = (
apiClient: MittwaldAPIV2Client,
Expand All @@ -43,7 +50,7 @@ export function makeProjectFlagSet<TName extends ContextNames>(
displayName = name,
supportsContext = false,
} = opts;
const article = displayName.match(/^[aeiou]/i) ? "an" : "a";
const article = articleForWord(displayName);

const flagName: ContextKey<TName> = `${name}-id`;
const flags = {
Expand Down
24 changes: 17 additions & 7 deletions src/rendering/react/RenderBaseCommand.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { CommandArgs, CommandFlags } from "../../types.js";
import { useIncreaseInkStdoutColumns } from "./hooks/useIncreaseInkStdoutColumns.js";
import { usePromise } from "@mittwald/react-use-promise";
import { CommandType } from "../../lib/context_flags.js";
import ErrorBoundary from "./components/ErrorBoundary.js";

const renderFlags = {
output: Flags.string({
Expand Down Expand Up @@ -53,7 +54,14 @@ export abstract class RenderBaseCommand<
}

public async run(): Promise<void> {
render(
const onError = () => {
setImmediate(() => {
handle.unmount();
process.exit(1);
});
};

const handle = render(
<RenderContextProvider
value={{
apiClient: this.apiClient,
Expand All @@ -62,12 +70,14 @@ export abstract class RenderBaseCommand<
>
<JsonCollectionProvider>
<Suspense>
<Render
render={() => {
useIncreaseInkStdoutColumns();
return this.render();
}}
/>
<ErrorBoundary onError={onError}>
<Render
render={() => {
useIncreaseInkStdoutColumns();
return this.render();
}}
/>
</ErrorBoundary>
</Suspense>
</JsonCollectionProvider>
</RenderContextProvider>,
Expand Down
118 changes: 118 additions & 0 deletions src/rendering/react/components/Error/APIError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import {
ApiClientError,
AxiosResponseHeaders,
} from "@mittwald/api-client-commons";
import { Box, Text } from "ink";
import { RawAxiosResponseHeaders } from "axios";
import ErrorStack from "./ErrorStack.js";
import ErrorText from "./ErrorText.js";
import ErrorBox from "./ErrorBox.js";

function RequestHeaders({ headers }: { headers: string }) {
const lines = headers.trim().split("\r\n");
const requestLine = lines.shift();
const values = lines.map((line) => line.split(": ", 2)) as [string, string][];
const maxKeyLength = Math.max(...values.map(([key]) => key.length));

return (
<Box flexDirection="column">
<Text bold underline>
{requestLine}
</Text>
{values.map(([key, value]) => (
<Box flexDirection="row" key={key}>
<Text dimColor>{key.toLowerCase().padEnd(maxKeyLength, " ")} </Text>
<Text bold>{key === "x-access-token" ? "[redacted]" : value}</Text>
</Box>
))}
</Box>
);
}

function Response({
status,
statusText,
body,
headers,
}: {
status: number;
statusText: string;
body: unknown;
headers: RawAxiosResponseHeaders | AxiosResponseHeaders;
}) {
const keys = Object.keys(headers);
const maxKeyLength = Math.max(...keys.map((key) => key.length));
return (
<Box flexDirection="column">
<Text bold underline key="status">
{status} {statusText}
</Text>
{keys.map((key) => (
<Box flexDirection="row" key={key}>
<Text dimColor>{key.toLowerCase().padEnd(maxKeyLength, " ")} </Text>
<Text bold>
{key === "x-access-token" ? "[redacted]" : headers[key]}
</Text>
</Box>
))}
<Box marginTop={1} key="body">
<Text>{JSON.stringify(body, undefined, 2)}</Text>
</Box>
</Box>
);
}

function HttpMessages({ err }: { err: ApiClientError }) {
const response = err.response ? (
<Response
status={err.response.status!}
statusText={err.response.statusText}
body={err.response.data}
headers={err.response.headers}
/>
) : (
<Text>no response received</Text>
);

return (
<Box marginX={2} marginY={1} flexDirection="column" rowGap={1}>
<RequestHeaders headers={err.request._header} />
{response}
</Box>
);
}

interface APIErrorProps {
err: ApiClientError;
withStack: boolean;
withHTTPMessages: "no" | "body" | "full";
}

/**
* Render an API client error to the terminal. In the case of an API client
* error, the error message will be displayed, as well as (when enabled) the
* request and response headers and body.
*/
export default function APIError({
err,
withStack,
withHTTPMessages,
}: APIErrorProps) {
return (
<>
<ErrorBox>
<ErrorText bold underline>
API CLIENT ERROR
</ErrorText>
<ErrorText>
An error occurred while communicating with the API: {err.message}
</ErrorText>

<Text>{JSON.stringify(err.response?.data, undefined, 2)}</Text>
</ErrorBox>

{withHTTPMessages === "full" ? <HttpMessages err={err} /> : undefined}
{withStack && "stack" in err ? <ErrorStack err={err} /> : undefined}
</>
);
}
20 changes: 20 additions & 0 deletions src/rendering/react/components/Error/ErrorBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Box, BoxProps } from "ink";
import { PropsWithChildren } from "react";

const defaultErrorBoxProps: BoxProps = {
width: 80,
flexDirection: "column",
borderColor: "red",
borderStyle: "round",
paddingX: 1,
rowGap: 1,
};

/** A pre-styled box for displaying errors. */
export default function ErrorBox(props: PropsWithChildren<BoxProps>) {
return (
<Box {...defaultErrorBoxProps} {...props}>
{props.children}
</Box>
);
}
17 changes: 17 additions & 0 deletions src/rendering/react/components/Error/ErrorStack.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Box } from "ink";
import ErrorText from "./ErrorText.js";

/** Render the stack trace of an error. */
export default function ErrorStack({ err }: { err: Error }) {
return (
<Box marginX={2} marginY={1} flexDirection="column" rowGap={1}>
<ErrorText dimColor bold>
ERROR STACK TRACE
</ErrorText>
<ErrorText dimColor>
Please provide this when opening a bug report.
</ErrorText>
<ErrorText dimColor>{err.stack}</ErrorText>
</Box>
);
}
10 changes: 10 additions & 0 deletions src/rendering/react/components/Error/ErrorText.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Text, TextProps } from "ink";

/** A pre-styled text for displaying errors. */
export default function ErrorText(props: TextProps) {
return (
<Text color="red" {...props}>
{props.children}
</Text>
);
}
45 changes: 45 additions & 0 deletions src/rendering/react/components/Error/GenericError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Box } from "ink";
import ErrorStack from "./ErrorStack.js";
import ErrorText from "./ErrorText.js";
import ErrorBox from "./ErrorBox.js";

const issueURL = "https://github.com/mittwald/cli/issues/new";

interface GenericErrorProps {
err: Error;
withStack: boolean;
withIssue?: boolean;
title?: string;
}

/**
* Render a generic error to the terminal. This is used for errors that don't
* have a specific rendering function.
*/
export default function GenericError({
err,
withStack,
withIssue = true,
title = "Error",
}: GenericErrorProps) {
return (
<>
<ErrorBox>
<ErrorText bold underline>
{title.toUpperCase()}
</ErrorText>
<ErrorText>An error occurred while executing this command:</ErrorText>
<Box marginX={2}>
<ErrorText>{err.toString()}</ErrorText>
</Box>
{withIssue ? (
<ErrorText>
If you believe this to be a bug, please open an issue at {issueURL}.
</ErrorText>
) : undefined}
</ErrorBox>

{withStack && "stack" in err ? <ErrorStack err={err} /> : undefined}
</>
);
}
Loading

0 comments on commit d8076f7

Please sign in to comment.