Skip to content

Commit

Permalink
Implement own custom Error using ADT style implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
Borewit committed Aug 29, 2024
1 parent 04fd5ee commit 8fe9ac2
Show file tree
Hide file tree
Showing 33 changed files with 238 additions and 71 deletions.
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,33 @@ readMetadata();
- [AWS SDK for JavaScript](https://aws.amazon.com/sdk-for-javascript/) - Documentation on using the AWS SDK to interact with S3 and other AWS services.
- [@tokenizer/s3](https://github.com/Borewit/tokenizer-s3) - Example of `ITokenizer` implementation.

### Handling Parse Errors

`music-metadata` provides a robust and extensible error handling system with custom error classes that inherit from the standard JavaScript `Error`.
All possible parsing errors are part of a union type `UnionOfParseErrors`, ensuring that every error scenario is accounted for in your code.

#### Union of Parse Errors

All parsing errors extend from the base class `ParseError` and are included in the `UnionOfParseErrors` type:
```ts
export type UnionOfParseErrors =
| CouldNotDetermineFileTypeError
| UnsupportedFileTypeError
| UnexpectedFileContentError
| FieldDecodingError
| InternalParserError;
```

#### Error Types

- `CouldNotDetermineFileTypeError`: Raised when the file type cannot be determined.
- `UnsupportedFileTypeError`: Raised when an unsupported file type is encountered.
- `UnexpectedFileContentError`: Raised when the file content does not match the expected format.
- `FieldDecodingError`: Raised when a specific field in the file cannot be decoded.
- `InternalParserError`: Raised for internal parser errors.

### Other functions

#### `orderTags` function

Utility to Converts the native tags to a dictionary index on the tag identifier
Expand Down
52 changes: 52 additions & 0 deletions lib/ParseError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
export type UnionOfParseErrors =
| CouldNotDetermineFileTypeError
| UnsupportedFileTypeError
| UnexpectedFileContentError
| FieldDecodingError
| InternalParserError;

export const makeParseError = <Name extends string>(name: Name) => {
return class ParseError extends Error {
name: Name
constructor(message: string) {
super(message);
this.name = name;
}
}
}

// Concrete error class representing a file type determination failure.
export class CouldNotDetermineFileTypeError extends makeParseError('CouldNotDetermineFileTypeError') {
}

// Concrete error class representing an unsupported file type.
export class UnsupportedFileTypeError extends makeParseError('UnsupportedFileTypeError') {
}

// Concrete error class representing unexpected file content.
class UnexpectedFileContentError extends makeParseError('UnexpectedFileContentError') {
constructor(public readonly fileType: string, message: string) {
super(message);
}

// Override toString to include file type information.
toString(): string {
return `${this.name} (FileType: ${this.fileType}): ${this.message}`;
}
}

// Concrete error class representing a field decoding error.
export class FieldDecodingError extends makeParseError('FieldDecodingError') {
}

export class InternalParserError extends makeParseError('InternalParserError') {
}

// Factory function to create a specific type of UnexpectedFileContentError.
export const makeUnexpectedFileContentError = <FileType extends string>(fileType: FileType) => {
return class extends UnexpectedFileContentError {
constructor(message: string) {
super(fileType, message);
}
};
};
20 changes: 10 additions & 10 deletions lib/ParserFactory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { fileTypeFromBuffer } from 'file-type';
import ContentType from 'content-type';
import {parse as mimeTypeParse, type MediaType} from 'media-typer';
import { type MediaType, parse as mimeTypeParse } from 'media-typer';
import initDebug from 'debug';

import { type INativeMetadataCollector, MetadataCollector } from './common/MetadataCollector.js';
Expand All @@ -18,8 +18,9 @@ import { DsfParser } from './dsf/DsfParser.js';
import { DsdiffParser } from './dsdiff/DsdiffParser.js';
import { MatroskaParser } from './matroska/MatroskaParser.js';

import type { IOptions, IAudioMetadata, ParserType } from './type.js';
import type { IAudioMetadata, IOptions, ParserType } from './type.js';
import type { ITokenizer } from 'strtok3';
import { CouldNotDetermineFileTypeError, InternalParserError, UnsupportedFileTypeError } from './ParseError.js';

const debug = initDebug('music-metadata:parser:factory');

Expand Down Expand Up @@ -79,23 +80,22 @@ export async function parseOnContentType(tokenizer: ITokenizer, opts?: IOptions)
export async function parse(tokenizer: ITokenizer, parserId?: ParserType, opts?: IOptions): Promise<IAudioMetadata> {

if (!parserId) {
// Parser could not be determined on MIME-type or extension
debug('Guess parser on content...');

const buf = new Uint8Array(4100);
await tokenizer.peekBuffer(buf, {mayBeLess: true});
if (tokenizer.fileInfo.path) {
parserId = getParserIdForExtension(tokenizer.fileInfo.path);
}
if (!parserId) {
// Parser could not be determined on MIME-type or extension
debug('Guess parser on content...');
const buf = new Uint8Array(4100);
await tokenizer.peekBuffer(buf, {mayBeLess: true});
const guessedType = await fileTypeFromBuffer(buf);
if (!guessedType) {
throw new Error('Failed to determine audio format');
throw new CouldNotDetermineFileTypeError('Failed to determine audio format');
}
debug(`Guessed file type is mime=${guessedType.mime}, extension=${guessedType.ext}`);
parserId = getParserIdForMimeType(guessedType.mime);
if (!parserId) {
throw new Error(`Guessed MIME-type not supported: ${guessedType.mime}`);
throw new UnsupportedFileTypeError(`Guessed MIME-type not supported: ${guessedType.mime}`);
}
}
}
Expand Down Expand Up @@ -202,7 +202,7 @@ export async function loadParser(moduleName: ParserType): Promise<ITokenParser>
case 'wavpack': return new WavPackParser();
case 'matroska': return new MatroskaParser();
default:
throw new Error(`Unknown parser type: ${moduleName}`);
throw new InternalParserError(`Unknown parser type: ${moduleName}`);
}
}

Expand Down
8 changes: 4 additions & 4 deletions lib/aiff/AiffParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { FourCcToken } from '../common/FourCC.js';
import { BasicParser } from '../common/BasicParser.js';

import * as AiffToken from './AiffToken.js';
import { AiffContentError, type CompressionTypeCode, compressionTypes } from './AiffToken.js';
import * as iff from '../iff/index.js';
import { type CompressionTypeCode, compressionTypes } from './AiffToken.js';

const debug = initDebug('music-metadata:parser:aiff');

Expand All @@ -27,7 +27,7 @@ export class AIFFParser extends BasicParser {

const header = await this.tokenizer.readToken<iff.IChunkHeader>(iff.Header);
if (header.chunkID !== 'FORM')
throw new Error('Invalid Chunk-ID, expected \'FORM\''); // Not AIFF format
throw new AiffContentError('Invalid Chunk-ID, expected \'FORM\''); // Not AIFF format

const type = await this.tokenizer.readToken<string>(FourCcToken);
switch (type) {
Expand All @@ -43,7 +43,7 @@ export class AIFFParser extends BasicParser {
break;

default:
throw new Error(`Unsupported AIFF type: ${type}`);
throw new AiffContentError(`Unsupported AIFF type: ${type}`);
}
this.metadata.setFormat('lossless', !this.isCompressed);

Expand All @@ -70,7 +70,7 @@ export class AIFFParser extends BasicParser {

case 'COMM': { // The Common Chunk
if (this.isCompressed === null) {
throw new Error('Failed to parse AIFF.COMM chunk when compression type is unknown');
throw new AiffContentError('Failed to parse AIFF.COMM chunk when compression type is unknown');
}
const common = await this.tokenizer.readToken<AiffToken.ICommon>(new AiffToken.Common(header, this.isCompressed));
this.metadata.setFormat('bitsPerSample', common.sampleSize);
Expand Down
8 changes: 6 additions & 2 deletions lib/aiff/AiffToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { FourCcToken } from '../common/FourCC.js';
import type * as iff from '../iff/index.js';

import type { IGetToken } from 'strtok3';
import { makeUnexpectedFileContentError } from '../ParseError.js';

export const compressionTypes = {
NONE: 'not compressed PCM Apple Computer',
Expand All @@ -19,6 +20,9 @@ export const compressionTypes = {

export type CompressionTypeCode = keyof typeof compressionTypes;

export class AiffContentError extends makeUnexpectedFileContentError('AIFF'){
}

/**
* The Common Chunk.
* Describes fundamental parameters of the waveform data such as sample rate, bit resolution, and how many channels of
Expand All @@ -39,7 +43,7 @@ export class Common implements IGetToken<ICommon> {

public constructor(header: iff.IChunkHeader, private isAifc: boolean) {
const minimumChunkSize = isAifc ? 22 : 18;
if (header.chunkSize < minimumChunkSize) throw new Error(`COMMON CHUNK size should always be at least ${minimumChunkSize}`);
if (header.chunkSize < minimumChunkSize) throw new AiffContentError(`COMMON CHUNK size should always be at least ${minimumChunkSize}`);
this.len = header.chunkSize;
}

Expand All @@ -65,7 +69,7 @@ export class Common implements IGetToken<ICommon> {
if (23 + strLen + padding === this.len) {
res.compressionName = new Token.StringType(strLen, 'latin1').get(buf, off + 23);
} else {
throw new Error('Illegal pstring length');
throw new AiffContentError('Illegal pstring length');
}
} else {
res.compressionName = undefined;
Expand Down
10 changes: 7 additions & 3 deletions lib/apev2/APEv2Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
TagFooter,
TagItemHeader
} from './APEv2Token.js';
import { makeUnexpectedFileContentError } from '../ParseError.js';

const debug = initDebug('music-metadata:parser:APEv2');

Expand All @@ -30,6 +31,9 @@ interface IApeInfo {

const preamble = 'APETAGEX';

export class ApeContentError extends makeUnexpectedFileContentError('APEv2'){
}

export class APEv2Parser extends BasicParser {

public static tryParseApeHeader(metadata: INativeMetadataCollector, tokenizer: strtok3.ITokenizer, options: IOptions) {
Expand Down Expand Up @@ -72,7 +76,7 @@ export class APEv2Parser extends BasicParser {

private static parseTagFooter(metadata: INativeMetadataCollector, buffer: Uint8Array, options: IOptions): Promise<void> {
const footer = TagFooter.get(buffer, buffer.length - TagFooter.len);
if (footer.ID !== preamble) throw new Error('Unexpected APEv2 Footer ID preamble value.');
if (footer.ID !== preamble) throw new ApeContentError('Unexpected APEv2 Footer ID preamble value');
strtok3.fromBuffer(buffer);
const apeParser = new APEv2Parser();
apeParser.init(metadata, strtok3.fromBuffer(buffer), options);
Expand Down Expand Up @@ -110,7 +114,7 @@ export class APEv2Parser extends BasicParser {

const descriptor = await this.tokenizer.readToken<IDescriptor>(DescriptorParser);

if (descriptor.ID !== 'MAC ') throw new Error('Unexpected descriptor ID');
if (descriptor.ID !== 'MAC ') throw new ApeContentError('Unexpected descriptor ID');
this.ape.descriptor = descriptor;
const lenExp = descriptor.descriptorBytes - DescriptorParser.len;
const header = await (lenExp > 0 ? this.parseDescriptorExpansion(lenExp) : this.parseHeader());
Expand Down Expand Up @@ -201,7 +205,7 @@ export class APEv2Parser extends BasicParser {
this.metadata.setFormat('duration', APEv2Parser.calculateDuration(header));

if (!this.ape.descriptor) {
throw new Error('Missing APE descriptor');
throw new ApeContentError('Missing APE descriptor');
}

return {
Expand Down
6 changes: 5 additions & 1 deletion lib/asf/AsfObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import type { AnyTagValue, IPicture, ITag } from '../type.js';
import GUID from './GUID.js';
import { getParserForAttr, parseUnicodeAttr } from './AsfUtil.js';
import { AttachedPictureType } from '../id3v2/ID3v2Token.js';
import { makeUnexpectedFileContentError } from '../ParseError.js';

export class AsfContentParseError extends makeUnexpectedFileContentError('ASF'){
}

/**
* Data Type: Specifies the type of information being stored. The following values are recognized.
Expand Down Expand Up @@ -113,7 +117,7 @@ export abstract class State<T> implements IGetToken<T> {
} else {
const parseAttr = getParserForAttr(valueType);
if (!parseAttr) {
throw new Error(`unexpected value headerType: ${valueType}`);
throw new AsfContentParseError(`unexpected value headerType: ${valueType}`);
}
tags.push({id: name, value: parseAttr(data as Uint8Array)});
}
Expand Down
4 changes: 3 additions & 1 deletion lib/asf/AsfParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { type ITag, TrackType } from '../type.js';
import GUID from './GUID.js';
import * as AsfObject from './AsfObject.js';
import { BasicParser } from '../common/BasicParser.js';
import { AsfContentParseError } from './AsfObject.js';


const debug = initDebug('music-metadata:parser:ASF');
const headerType = 'asf';
Expand All @@ -23,7 +25,7 @@ export class AsfParser extends BasicParser {
public async parse() {
const header = await this.tokenizer.readToken<AsfObject.IAsfTopLevelObjectHeader>(AsfObject.TopLevelHeaderObjectToken);
if (!header.objectId.equals(GUID.HeaderObject)) {
throw new Error(`expected asf header; but was not found; got: ${header.objectId.str}`);
throw new AsfContentParseError(`expected asf header; but was not found; got: ${header.objectId.str}`);
}
try {
await this.parseObjectHeader(header.numberOfHeaderObjects);
Expand Down
3 changes: 2 additions & 1 deletion lib/common/CombinedTagMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type { ITag } from '../type.js';
import type { INativeMetadataCollector } from './MetadataCollector.js';
import { MatroskaTagMapper } from '../matroska/MatroskaTagMapper.js';
import { AiffTagMapper } from '../aiff/AiffTagMap.js';
import { InternalParserError } from '../ParseError.js';

export class CombinedTagMapper {

Expand Down Expand Up @@ -47,7 +48,7 @@ export class CombinedTagMapper {
if (tagMapper) {
return this.tagMappers[tagType].mapGenericTag(tag, warnings);
}
throw new Error(`No generic tag mapper defined for tag-format: ${tagType}`);
throw new InternalParserError(`No generic tag mapper defined for tag-format: ${tagType}`);
}

private registerTagMapper(genericTagMapper: IGenericTagMapper) {
Expand Down
5 changes: 3 additions & 2 deletions lib/common/FourCC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { IToken } from 'strtok3';
import { stringToUint8Array, uint8ArrayToString } from 'uint8array-extras';

import * as util from './Util.js';
import { InternalParserError, FieldDecodingError } from '../ParseError.js';

const validFourCC = /^[\x21-\x7e©][\x20-\x7e\x00()]{3}/;

Expand All @@ -15,15 +16,15 @@ export const FourCcToken: IToken<string> = {
get: (buf: Uint8Array, off: number): string => {
const id = uint8ArrayToString(buf.slice(off, off + FourCcToken.len), 'latin1');
if (!id.match(validFourCC)) {
throw new Error(`FourCC contains invalid characters: ${util.a2hex(id)} "${id}"`);
throw new FieldDecodingError(`FourCC contains invalid characters: ${util.a2hex(id)} "${id}"`);
}
return id;
},

put: (buffer: Uint8Array, offset: number, id: string) => {
const str = stringToUint8Array(id);
if (str.length !== 4)
throw new Error('Invalid length');
throw new InternalParserError('Invalid length');
buffer.set(str, offset);
return offset + 4;
}
Expand Down
6 changes: 3 additions & 3 deletions lib/common/Util.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { StringType } from 'token-types';
import type { IRatio } from '../type.js';
import { FieldDecodingError } from '../ParseError.js';

export type StringEncoding =
'ascii' // Use 'utf-8' or latin1 instead
Expand Down Expand Up @@ -45,7 +46,7 @@ export function trimRightNull(x: string): string {

function swapBytes<T extends Uint8Array>(uint8Array: T): T {
const l = uint8Array.length;
if ((l & 1) !== 0) throw new Error('Buffer length must be even');
if ((l & 1) !== 0) throw new FieldDecodingError('Buffer length must be even');
for (let i = 0; i < l; i += 2) {
const a = uint8Array[i];
uint8Array[i] = uint8Array[i + 1];
Expand All @@ -54,7 +55,6 @@ function swapBytes<T extends Uint8Array>(uint8Array: T): T {
return uint8Array;
}


/**
* Decode string
*/
Expand All @@ -66,7 +66,7 @@ export function decodeString(uint8Array: Uint8Array, encoding: StringEncoding):
}if (encoding === 'utf-16le' && uint8Array[0] === 0xFE && uint8Array[1] === 0xFF) {
// BOM, indicating big endian decoding
if ((uint8Array.length & 1) !== 0)
throw new Error('Expected even number of octets for 16-bit unicode string');
throw new FieldDecodingError('Expected even number of octets for 16-bit unicode string');
return decodeString(swapBytes(uint8Array), encoding);
}
return new StringType(uint8Array.length, encoding).get(uint8Array, 0);
Expand Down
2 changes: 2 additions & 0 deletions lib/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type { IFileInfo } from 'strtok3';

export { type IAudioMetadata, type IOptions, type ITag, type INativeTagDict, type ICommonTagsResult, type IFormat, type IPicture, type IRatio, type IChapter, type ILyricsTag, LyricsContentType, TimestampFormat, IMetadataEventTag, IMetadataEvent } from './type.js';

export type * from './ParseError.js'

/**
* Parse Web API File
* Requires Blob to be able to stream using a ReadableStreamBYOBReader, only available since Node.js ≥ 20
Expand Down
Loading

0 comments on commit 8fe9ac2

Please sign in to comment.