Skip to content

Commit

Permalink
Merge pull request #1 from suzuki3jp/develop
Browse files Browse the repository at this point in the history
Develop
  • Loading branch information
suzuki3jp authored Jan 19, 2025
2 parents 3dadd28 + 9fed846 commit 642cba0
Show file tree
Hide file tree
Showing 13 changed files with 382 additions and 127 deletions.
33 changes: 33 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: CI
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
workflow_dispatch:

jobs:
vitest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install dependencies
run: npm ci
- name: Test
run: npm run test
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
> This project is in the early stages of development.
> There may be many bugs remaining.
![NPM Version](https://img.shields.io/npm/v/youtubes.js)
![NPM Downloads](https://img.shields.io/npm/dm/youtubes.js)

## Table of Contents
<!-- no toc -->
Expand All @@ -22,7 +24,7 @@
## Highlights
- Full object-oriented architecture
- Complete TypeScript type definitions
- Robust error handling with `Result` type (coming soon)
- Robust error handling with `Result` type
- Built-in request pagination

## Quick Start
Expand Down
29 changes: 28 additions & 1 deletion docs/01-introduction.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Introduction
## What's `youtubes.js`?
`youtubes.js` is a JavaScript library for interacting YouTube Data API v3.
Type-safe, Object-oriented, handling errors with `Result` (coming soon)
Type-safe, Object-oriented, handling errors with `Result`

## Installation
`youtubes.js` supports LTS nodejs version.
Expand Down Expand Up @@ -37,6 +37,33 @@ async function main() {
main();
```

## Handling errors
All public methods of `youtube.js` return `Result` of [`result4js`](https://github.com/suzuki3jp/result4js).
Using `Result` enables type-safe error handling.
> If you want to abandon type safety and perform dangerous error handling, you can use [`Result#throw`](https://github.com/suzuki3jp/result4js?tab=readme-ov-file#usage).
```ts
import { ApiClient, StaticOAuthProvider } from "youtubes.js";

async function main() {
const oauth = new StaticOAuthProvider({
accessToken: "YOUR_ACCESS_TOKEN",
});
const client = new ApiClient({ oauth });

const playlistsResult = await client.playlists.getMine();

if (playlists.isErr()) {
// Handle error case
return;
}

const playlists = playlistsResult.data;
}

main();
```

## Logging
Configure logging level in the `ApiClient` constructor:
```ts
Expand Down
38 changes: 27 additions & 11 deletions src/Pagination.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { Err, Ok, type Result } from "result4js";

import type { Logger } from "./Logger";
import { LIKELY_BUG } from "./constants";
import type { YouTubesJsErrors } from "./errors";
import { isNullish } from "./utils";

/**
* Provides utility methods for pagination.
Expand Down Expand Up @@ -37,7 +41,9 @@ export class Pagination<T> {
* Fetches the page with the given token.
* @param token - The token of the page to fetch.
*/
private getWithToken: (token: string) => Promise<Pagination<T>>;
private getWithToken: (
token: string,
) => Promise<Result<Pagination<T>, YouTubesJsErrors>>;

private logger: Logger;

Expand All @@ -56,7 +62,7 @@ export class Pagination<T> {
this.nextToken = nextToken ?? null;
this.getWithToken = getWithToken;

if (!resultsPerPage || !totalResults) {
if (isNullish(resultsPerPage) || isNullish(totalResults)) {
this.logger.debug("resultsPerPage or totalResults is not provided");
this.logger.debug(
"resultsPerPage and totalResults are expected to be included in the API response",
Expand Down Expand Up @@ -89,7 +95,10 @@ export class Pagination<T> {
* console.log(prevPage?.data); // The previous page of playlists or null if there is no previous page
* ```
*/
public async prev(): Promise<Pagination<T> | null> {
public async prev(): Promise<Result<
Pagination<T>,
YouTubesJsErrors
> | null> {
if (!this.prevToken) return null;
const data = await this.getWithToken(this.prevToken);
return data;
Expand Down Expand Up @@ -117,7 +126,10 @@ export class Pagination<T> {
* console.log(nextPage?.data); // The second page of playlists or null if there is no next page
* ```
*/
public async next(): Promise<Pagination<T> | null> {
public async next(): Promise<Result<
Pagination<T>,
YouTubesJsErrors
> | null> {
if (!this.nextToken) return null;
const data = await this.getWithToken(this.nextToken);
return data;
Expand All @@ -141,23 +153,25 @@ export class Pagination<T> {
* const allPlaylists = (await playlists.all()).flat();
* ```
*/
public async all(): Promise<T[]> {
public async all(): Promise<Result<T[], YouTubesJsErrors>> {
const result: T[] = [];
result.push(this.data);

let prev = await this.prev();
while (prev) {
result.unshift(prev.data);
prev = await prev.prev();
if (prev.isErr()) return Err(prev.data);
result.unshift(prev.data.data);
prev = await prev.data.prev();
}

let next = await this.next();
while (next) {
result.push(next.data);
next = await next.next();
if (next.isErr()) return Err(next.data);
result.push(next.data.data);
next = await next.data.next();
}

return result;
return Ok(result);
}
}

Expand All @@ -168,5 +182,7 @@ export interface PaginationOptions<T> {
nextToken?: string | null;
resultsPerPage?: number | null;
totalResults?: number | null;
getWithToken: (token: string) => Promise<Pagination<T>>;
getWithToken: (
token: string,
) => Promise<Result<Pagination<T>, YouTubesJsErrors>>;
}
23 changes: 21 additions & 2 deletions src/entities/playlist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { youtube_v3 } from "googleapis";
import { Err, Ok, type Result } from "result4js";

import type { Logger } from "../Logger";
import { LikelyBugError } from "../errors";
import { isNullish } from "../utils";
import { type Privacy, convertToPrivacy } from "./privacy";
import { Thumbnails } from "./thumbnails";
Expand Down Expand Up @@ -82,7 +83,7 @@ export class Playlist {
public static from(
data: youtube_v3.Schema$Playlist,
logger: Logger,
): Result<Playlist, string> {
): Result<Playlist, LikelyBugError> {
const currentLogger = logger.createChild("Playlist#from");

if (
Expand All @@ -101,7 +102,7 @@ export class Playlist {
currentLogger.debug("Generating Playlist instance from raw data.");
currentLogger.debug("Raw data:");
currentLogger.debug(JSON.stringify(data, null, "\t"));
return Err(message);
return Err(new LikelyBugError(message));
}

const thumbnails = Thumbnails.from(
Expand All @@ -126,6 +127,24 @@ export class Playlist {
}),
);
}

public static fromMany(
data: youtube_v3.Schema$Playlist[],
logger: Logger,
): Result<Playlist[], LikelyBugError> {
const currentLogger = logger.createChild("Playlist#fromMany");

const playlists: Playlist[] = [];
for (const playlist of data) {
const result = Playlist.from(playlist, currentLogger);
if (result.isErr()) {
return Err(result.data);
}
playlists.push(result.data);
}

return Ok(playlists);
}
}

interface PlaylistData {
Expand Down
12 changes: 9 additions & 3 deletions src/entities/privacy.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { Err, Ok, type Result } from "result4js";

import { LikelyBugError } from "../errors";

export type Privacy = "public" | "unlisted" | "private";

/**
* Converts a YouTube API raw data to a `Privacy` type.
* @param data
*/
export function convertToPrivacy(data?: string): Result<Privacy, string> {
if (!data) return Err("The raw data is missing.");
export function convertToPrivacy(
data?: string,
): Result<Privacy, LikelyBugError> {
if (!data) return Err(new LikelyBugError("The raw data is undefined."));

switch (data) {
case "public":
Expand All @@ -18,7 +22,9 @@ export function convertToPrivacy(data?: string): Result<Privacy, string> {
return Ok("private" as Privacy);
default:
return Err(
`The raw data is unexpected format. Expected "public", "unlisted", or "private".`,
new LikelyBugError(
`The raw data is unexpected format. Expected "public", "unlisted", or "private". Received: ${data}`,
),
);
}
}
7 changes: 5 additions & 2 deletions src/entities/thumbnails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { youtube_v3 } from "googleapis";
import { Err, Ok, type Result } from "result4js";

import type { Logger } from "../Logger";
import { LikelyBugError } from "../errors";
import { isNullish } from "../utils";

/**
Expand Down Expand Up @@ -31,7 +32,7 @@ export class Thumbnails {
public static from(
data: youtube_v3.Schema$ThumbnailDetails,
logger: Logger,
): Result<Thumbnails, string> {
): Result<Thumbnails, LikelyBugError> {
const isInvalid = Object.values(data)
.map((t) => {
if (!t) return false;
Expand All @@ -53,7 +54,9 @@ export class Thumbnails {
currentLogger.debug(JSON.stringify(data, null, "\t"));

return Err(
"The raw data is missing required fields. Each thumbnail (default, medium, high, standard, maxres) must include url, width, and height.",
new LikelyBugError(
"The raw data is missing required fields. Each thumbnail (default, medium, high, standard, maxres) must include url, width, and height.",
),
);
}

Expand Down
87 changes: 87 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { GaxiosError } from "gaxios";

export type YouTubesJsErrors = LikelyBugError | YouTubeApiError;

/**
* Represents an error that is likely a bug in the library.
*
* If you encounter this error, please report the issue on [GitHub Issue](https://github.com/suzuki3jp/youtubes.js/issues/new).
*/
export class LikelyBugError implements BaseError {
public type = "LIKELY_BUG";
public message: string;

constructor(message: string) {
this.message = message;
}

/**
* Converts the error to an `Error` object.
* @returns
*/
public toError(): Error {
const error = new Error(this.message);
error.name = "LikelyBugError";
return error;
}

/**
* Throws the error.
*/
public throw(): never {
throw this.toError();
}
}

/**
* Represents an error from the YouTube API.
*
* This error is thrown when the YouTube API returns an error response.
*/
export class YouTubeApiError implements BaseError {
public type = "YOUTUBE_API_ERROR";
public code: number;
public message: string;

constructor(code: number, message: string) {
this.code = code;
this.message = message;
}

/**
* Converts the error to an `Error` object.
* @returns
*/
public toError(): Error {
const error = new Error(`[${this.code}] ${this.message}`);
error.name = "YouTubeApiError";
return error;
}

/**
* Throws the error.
*/
public throw(): never {
throw this.toError();
}
}

interface BaseError {
type: string;
toError(): Error;
}

/**
* Handles an error from the YouTube API cathing in the try-catch block.
* @param error
*/
export function handleYouTubeApiError(error: unknown): YouTubesJsErrors {
if (error instanceof GaxiosError) {
const code = Number.parseInt(error.code || "500");
return new YouTubeApiError(code, error.message);
}

return new LikelyBugError(
"An unexpected error occurred in the call to the YouTube API.",
);
}
Loading

0 comments on commit 642cba0

Please sign in to comment.