Files exception filter

An exception filter for common file-related errors.

An interesting feature we could implement is an exception filter for files. However, it is important to understand that it will only catch errors related to file validation. In case an error of type ENOENT occurs (file or directory not found), it won't be handled. This is because this kind of error is related to problems in the code. So, if this happens, we should fix these bugs instead of throwing an exception. In some cases it may make sense to handle it, like in the downloadImage() method, as a filename for a non-existing file may be sent and thus, this problem is not code-related anymore.

This may remind of the discussion Checked/Unchecked exceptions, common in Java.

The idea is to make the file validation throw a specific type of exception in case of failure, so that this exception may be catched by the filter. Just for this purpose, we'll use the UNPROCESSABLE_ENTITY status. Once in the filter, a more adequate status will be used for the concrete case. What we'll do then, is to go back to the file-validation.util file and add a new function, responsible for creating a ParseFilePipe with the validators we defined previously and also the mentioned status.

export const createParseFilePipe = (
  maxSize: FileSize,
  ...fileTypes: NonEmptyArray<FileType>
) =>
  new ParseFilePipe({
    validators: createFileValidators(maxSize, fileTypes),
    errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY,
  });

We may then simplify the second parameter of createFileValidators() by removing the rest parameter, as this function is not called directly anymore.

fileTypes: NonEmptyArray<FileType>,

This way, back in the ProductsController, in uploadImages(), we even get a simpler result.

@UploadedFiles(createParseFilePipe('2MB', 'png', 'jpeg'))

You may be thinking: doesn't this increase the code coupling? As if a more specific configuration is desired for the ParseFilePipe, this function may not be employed. This is a valid thought. It depends on how this code is intended to be used. If it's assumed that, practically everywhere files are managed, this pattern will be used, then it may be alright. If more flexibility is needed, then another approach can be worked upon. And this may change during the project itself. In the end, it depends on the specific details of the business logic, and both the pros and cons of each possible approach should be considered.

Commit - Unprocessable entity as default exception for file validation

Now, let's create the exception filter. Also enable it globally in the FilesModule.

nest g f files/exception-filters/files-exception

Inside it, let's make it catch the previously defined exception.

@Catch(UnprocessableEntityException)
export class FilesExceptionFilter implements ExceptionFilter {
  catch(exception: UnprocessableEntityException, host: ArgumentsHost) {}
}

In the catch() method, obtain the response and extract the message from the exception.

const response = host.switchToHttp().getResponse<Response>();
const { message } = exception;

When dealing with file-related errors, we have two more possibilities of HTTP status. Let's then return to http-error.util in order to add them.

PAYLOAD_TOO_LARGE: {
  status: HttpStatus.PAYLOAD_TOO_LARGE,
  error: 'Payload Too Large',
},
UNSUPPORTED_MEDIA_TYPE: {
  status: HttpStatus.UNSUPPORTED_MEDIA_TYPE,
  error: 'Unsupported Media Type',
},

Now, as we have already done before, let's create some constants to aid us.

private readonly MessageSnippet = {
  MAX_SIZE: 'expected size',
  FILE_TYPE: 'expected type',
  FILE_SIGNATURE: 'does not match',
} as const satisfies Record<string, string>;

private readonly Description = {
  FILE_TYPE: 'Invalid file type',
  FILE_SIGNATURE: 'File type tampered',
} as const satisfies Record<string, string>;

There's no description for the max size as it is already descriptive enough.

Then, add new regexes for these situations.

private readonly MAX_FILE_SIZE_REGEX = /less than (\d+)/;
private readonly FILE_TYPES_REGEX = /\/(.*)\//;

The next step is to create methods for extracting the maxSize and fileTypes from the message. The maxSize is a number after "less than", and the fileTypes can be found between slashes, separated by pipes. Therefore, each method will have its own logic to extract the data.

As the maxSize is in bytes, the bytes library will be used again. However, this time the opposite process will occur: it will transform the value in bytes into a readable string.

Remember to import the bytes library correctly, adding * as.

The fileTypes have backslashes that should be removed for the next step. In the message, now the media types can be found, due to our previous fix. This time, we'll also perform the opposite process and obtain their respective extensions, using the extension() function from mime-types.

private extractMaxSize(message: string) {
  const maxSizeStr = extractFromText(message, this.MAX_FILE_SIZE_REGEX);

  const maxSizeInBytes = +maxSizeStr;

  const maxSize = bytes(maxSizeInBytes);
  return maxSize;
}

private extractFileTypes(message: string) {
  const mediaTypesStr = extractFromText(message, this.FILE_TYPES_REGEX);

  const mediaTypesWithBackslashes = mediaTypesStr.split('|');
  const mediaTypes = mediaTypesWithBackslashes.map((type) => type.replace('\\', ''));
  
  const fileTypes = mediaTypes.map((type) => extension(type));
  return fileTypes;
}

As the last step, let's create the method createErrorData(). It will return fields which some of them may or may not be generated depending on the error message.

private createErrorData(message: string) {
  let httpError: HttpError;
  let description: string;

  let maxSize: string;
  let expectedFileTypes: (string | false)[];

  if (message.includes(this.MessageSnippet.MAX_SIZE)) {
    httpError = HttpError.PAYLOAD_TOO_LARGE;
    maxSize = this.extractMaxSize(message);
  } else if (message.includes(this.MessageSnippet.FILE_TYPE)) {
    httpError = HttpError.UNSUPPORTED_MEDIA_TYPE;
    description = this.Description.FILE_TYPE;
    expectedFileTypes = this.extractFileTypes(message);
  } else if (message.includes(this.MessageSnippet.FILE_SIGNATURE)) {
    httpError = HttpError.UNSUPPORTED_MEDIA_TYPE;
    description = this.Description.FILE_SIGNATURE;
  } else {
    httpError = HttpError.BAD_REQUEST;
  }

  return { httpError, description, maxSize, expectedFileTypes };
}

If the message doesn't match any snippet, the BAD_REQUEST status is maintained alongside the original message. This is useful, for example, when no files are sent.

The expectedFileTypes has a mixed type because the extension() function returns false if it cannot identify the extension.

A switch cannot be used here because it is checked if the message includes the snippet.

The only remaining step now is to, in the catch() method, extract the data and compose the response.

const { httpError, ...meta } = this.createErrorData(message);
const { status, error } = httpError;

response.status(status).json({
  statusCode: status,
  message,
  error,
  meta,
});

With this, we have finished the files exception filter.

Commit - Creating the files exception filter

Last updated