diff --git a/src/index.ts b/src/index.ts index f298417..7768db2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import type NarouNovel from "./narou.js"; +import type { ExecuteOptions } from "./narou.js"; import NarouNovelFetch from "./narou-fetch.js"; import NarouNovelJsonp from "./narou-jsonp.js"; import RankingBuilder from "./ranking.js"; @@ -67,13 +68,16 @@ export function ranking(api: NarouNovel = narouNovelFetch): RankingBuilder { /** * なろう殿堂入り API でランキング履歴を取得する * @param {string} ncode 小説のNコード + * @param {ExecuteOptions} [options] 実行オプション + * @param {NarouNovel} [api] API実行クラスのインスタンス * @see https://dev.syosetu.com/man/rankinapi/ */ export async function rankingHistory( ncode: string, + options?: ExecuteOptions, api: NarouNovel = narouNovelFetch ): Promise { - const result = await api.executeRankingHistory({ ncode }); + const result = await api.executeRankingHistory({ ncode }, options); if (Array.isArray(result)) { return result.map(formatRankingHistory); } else { diff --git a/src/narou-fetch.ts b/src/narou-fetch.ts index 9c162b5..425cdeb 100644 --- a/src/narou-fetch.ts +++ b/src/narou-fetch.ts @@ -1,6 +1,6 @@ import { unzipp } from "./util/unzipp.js"; import NarouNovel from "./narou.js"; -import type { NarouParams } from "./narou.js"; +import type { NarouParams, ExecuteOptions } from "./narou.js"; type Fetch = typeof fetch; @@ -18,7 +18,8 @@ export default class NarouNovelFetch extends NarouNovel { protected async execute( params: NarouParams, - endpoint: string + endpoint: string, + options?: ExecuteOptions ): Promise { const query = { ...params, out: "json" }; @@ -36,7 +37,7 @@ export default class NarouNovelFetch extends NarouNovel { } }); - const res = await (this.fetch ?? fetch)(url); + const res = await (this.fetch ?? fetch)(url, options?.fetchOptions); if (!query.gzip) { return (await res.json()) as T; diff --git a/src/narou-jsonp.ts b/src/narou-jsonp.ts index 8f4ad01..7ef155e 100644 --- a/src/narou-jsonp.ts +++ b/src/narou-jsonp.ts @@ -1,5 +1,5 @@ import NarouNovel from "./narou.js"; -import type { NarouParams } from "./narou.js"; +import type { NarouParams, ExecuteOptions } from "./narou.js"; import { jsonp } from "./util/jsonp.js"; /** @@ -8,7 +8,9 @@ import { jsonp } from "./util/jsonp.js"; export default class NarouNovelJsonp extends NarouNovel { protected async execute( params: NarouParams, - endpoint: string + endpoint: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _options?: ExecuteOptions ): Promise { const query = { ...params, out: "jsonp" }; query.gzip = 0; diff --git a/src/narou.ts b/src/narou.ts index 5ff03ac..52f8a31 100644 --- a/src/narou.ts +++ b/src/narou.ts @@ -21,6 +21,16 @@ export type NarouParams = | RankingHistoryParams | UserSearchParams; +/** + * なろう小説APIへのリクエストオプション + */ +export interface ExecuteOptions { + /** + * fetch関数のオプション + */ + fetchOptions?: RequestInit; +} + /** * なろう小説APIへのリクエストを実行する * @class NarouNovel @@ -35,7 +45,8 @@ export default abstract class NarouNovel { */ protected abstract execute( params: NarouParams, - endpoint: string + endpoint: string, + options?: ExecuteOptions ): Promise; /** @@ -46,9 +57,13 @@ export default abstract class NarouNovel { */ protected async executeSearch( params: SearchParams, - endpoint = "https://api.syosetu.com/novelapi/api/" + endpoint = "https://api.syosetu.com/novelapi/api/", + options?: ExecuteOptions ): Promise> { - return new NarouSearchResults(await this.execute(params, endpoint), params); + return new NarouSearchResults( + await this.execute(params, endpoint, options), + params + ); } /** @@ -58,11 +73,13 @@ export default abstract class NarouNovel { * @see https://dev.syosetu.com/man/api/ */ async executeNovel( - params: SearchParams + params: SearchParams, + options?: ExecuteOptions ): Promise> { return await this.executeSearch( params, - "https://api.syosetu.com/novelapi/api/" + "https://api.syosetu.com/novelapi/api/", + options ); } @@ -73,11 +90,13 @@ export default abstract class NarouNovel { * @see https://dev.syosetu.com/xman/api/ */ async executeNovel18( - params: SearchParams + params: SearchParams, + options?: ExecuteOptions ): Promise> { return await this.executeSearch( params, - "https://api.syosetu.com/novel18api/api/" + "https://api.syosetu.com/novel18api/api/", + options ); } @@ -87,20 +106,33 @@ export default abstract class NarouNovel { * @returns ランキング結果 * @see https://dev.syosetu.com/man/rankapi/ */ - async executeRanking(params: RankingParams): Promise { - return await this.execute(params, "https://api.syosetu.com/rank/rankget/"); + async executeRanking( + params: RankingParams, + options?: ExecuteOptions + ): Promise { + return await this.execute( + params, + "https://api.syosetu.com/rank/rankget/", + options + ); } /** * 殿堂入りAPiへのリクエストを実行する * @param params クエリパラメータ + * @param options 実行オプション * @returns ランキング履歴結果 * @see https://dev.syosetu.com/man/rankinapi/ */ async executeRankingHistory( - params: RankingHistoryParams + params: RankingHistoryParams, + options?: ExecuteOptions ): Promise { - return await this.execute(params, "https://api.syosetu.com/rank/rankin/"); + return await this.execute( + params, + "https://api.syosetu.com/rank/rankin/", + options + ); } /** @@ -110,10 +142,15 @@ export default abstract class NarouNovel { * @see https://dev.syosetu.com/man/userapi/ */ async executeUserSearch( - params: UserSearchParams + params: UserSearchParams, + options?: ExecuteOptions ): Promise> { return new NarouSearchResults( - await this.execute(params, "https://api.syosetu.com/userapi/api/"), + await this.execute( + params, + "https://api.syosetu.com/userapi/api/", + options + ), params ); } diff --git a/src/ranking.ts b/src/ranking.ts index 21ec693..aea6fb6 100644 --- a/src/ranking.ts +++ b/src/ranking.ts @@ -11,6 +11,7 @@ import { Fields, } from "./params.js"; import type NarouNovel from "./narou.js"; +import type { ExecuteOptions } from "./narou.js"; import type { SearchResultFields } from "./narou-search-results.js"; import { addDays, formatDate } from "./util/date.js"; @@ -108,21 +109,24 @@ export default class RankingBuilder { * 設定されたパラメータに基づき、なろう小説ランキングAPIへのリクエストを実行します。 * * 返される結果には、Nコード、ポイント、順位が含まれます。 + * @param options 実行オプション * @returns {Promise} ランキング結果の配列 * @see https://dev.syosetu.com/man/rankapi/#output */ - execute(): Promise { + execute(options?: ExecuteOptions): Promise { const date = formatDate(this.date$); this.set({ rtype: `${date}-${this.type$}` }); - return this.api.executeRanking(this.params as RankingParams); + return this.api.executeRanking(this.params as RankingParams, options); } /** * ランキングAPIを実行し、取得したNコードを元になろう小説APIで詳細情報を取得して結合します。 */ - async executeWithFields(): Promise< - RankingResult[] - >; + async executeWithFields( + fields?: never[] | undefined, + opt?: never[] | undefined, + options?: ExecuteOptions + ): Promise[]>; /** * ランキングAPIを実行し、取得したNコードを元になろう小説APIで詳細情報を取得して結合します。 * @@ -131,7 +135,9 @@ export default class RankingBuilder { * @returns {Promise>[]>} 詳細情報を含むランキング結果の配列 */ async executeWithFields( - fields: TFields | TFields[] + fields: TFields | TFields[], + opt?: never | never[], + options?: ExecuteOptions ): Promise>[]>; /** * ランキングAPIを実行し、取得したNコードを元になろう小説APIで詳細情報を取得して結合します。 @@ -141,7 +147,8 @@ export default class RankingBuilder { */ async executeWithFields( fields: never[], - opt: OptionalFields | OptionalFields[] + opt: OptionalFields | OptionalFields[], + options?: ExecuteOptions ): Promise[]>; /** * ランキングAPIを実行し、取得したNコードを元になろう小説APIで詳細情報を取得して結合します。 @@ -153,7 +160,8 @@ export default class RankingBuilder { */ async executeWithFields( fields: TFields | TFields[], - opt: OptionalFields | OptionalFields[] + opt: OptionalFields | OptionalFields[], + options?: ExecuteOptions ): Promise | "weekly_unique">[]>; /** * ランキングAPIを実行し、取得したNコードを元になろう小説APIで詳細情報を取得して結合します。 @@ -169,9 +177,10 @@ export default class RankingBuilder { TOpt extends OptionalFields | undefined = undefined >( fields: TFields | TFields[] = [], - opt?: TOpt + opt?: TOpt, + options?: ExecuteOptions ): Promise>[]> { - const ranking = await this.execute(); + const ranking = await this.execute(options); const fields$ = Array.isArray(fields) ? fields.length == 0 ? [] @@ -186,7 +195,7 @@ export default class RankingBuilder { } builder.ncode(rankingNcodes); builder.limit(ranking.length); - const result = await builder.execute(); + const result = await builder.execute(options); return ranking.map< RankingResult< diff --git a/src/search-builder-r18.ts b/src/search-builder-r18.ts index 02e961c..498b43a 100644 --- a/src/search-builder-r18.ts +++ b/src/search-builder-r18.ts @@ -1,4 +1,5 @@ import { NovelSearchBuilderBase } from "./search-builder.js"; +import type { ExecuteOptions } from "./narou.js"; import type NarouSearchResults from "./narou-search-results.js"; import type { NarouSearchResult, @@ -28,10 +29,13 @@ export default class SearchBuilderR18< /** * なろう小説APIへの検索リクエストを実行する * @override + * @param options 実行オプション * @returns {Promise} 検索結果 */ - execute(): Promise> { - return this.api.executeNovel18(this.params); + execute( + options?: ExecuteOptions + ): Promise> { + return this.api.executeNovel18(this.params, options); } /** diff --git a/src/search-builder.ts b/src/search-builder.ts index 30f871d..4e82a99 100644 --- a/src/search-builder.ts +++ b/src/search-builder.ts @@ -1,4 +1,5 @@ import type NarouNovel from "./narou.js"; +import type { ExecuteOptions } from "./narou.js"; import type { NarouSearchResult, SearchResultFields, @@ -40,7 +41,7 @@ export abstract class SearchBuilderBase< constructor( protected params: TParams = {} as TParams, protected api: NarouNovel - ) {} + ) { } /** * 配列から重複を除去する @@ -472,10 +473,13 @@ export abstract class NovelSearchBuilderBase< /** * なろう小説APIへの検索リクエストを実行する + * @param options 実行オプション * @returns {Promise} 検索結果 */ - execute(): Promise> { - return this.api.executeNovel(this.params); + execute( + options?: ExecuteOptions + ): Promise> { + return this.api.executeNovel(this.params, options); } } diff --git a/src/user-search.ts b/src/user-search.ts index eb607d9..09c1fe5 100644 --- a/src/user-search.ts +++ b/src/user-search.ts @@ -5,6 +5,7 @@ import type { } from "./narou-search-results.js"; import type { UserFields, UserOrder, UserSearchParams } from "./params.js"; import { SearchBuilderBase } from "./search-builder.js"; +import type { ExecuteOptions } from "./narou.js"; /** * なろうユーザ検索API @@ -102,9 +103,15 @@ export default class UserSearchBuilder< /** * なろう小説APIへのリクエストを実行する + * @param options 実行オプション * @returns ランキング */ - execute(): Promise> { - return this.api.executeUserSearch(this.params as UserSearchParams); + execute( + options?: ExecuteOptions + ): Promise> { + return this.api.executeUserSearch( + this.params as UserSearchParams, + options + ); } } diff --git a/test/narou-fetch.test.ts b/test/narou-fetch.test.ts index f5578c4..6f85d17 100644 --- a/test/narou-fetch.test.ts +++ b/test/narou-fetch.test.ts @@ -166,4 +166,23 @@ describe('NarouNovelFetch', () => { // リクエストが呼ばれたことを確認 expect(requestSpy).toHaveBeenCalled(); }); + + it('should pass fetchOptions to fetch', async () => { + // MSWでエンドポイントをモック + server.use( + http.get('https://api.example.com', ({ request }) => { + expect(request.headers.get('user-agent')).toBe('node-narou'); + return responseGzipOrJson(mockData, new URL(request.url)); + }) + ); + + const narouFetch = new NarouNovelFetch(); + + // @ts-expect-error - Accessing protected method for testing + await narouFetch.execute( + { gzip: 0 }, + 'https://api.example.com', + { fetchOptions: { headers: { 'user-agent': 'node-narou' } } } + ); + }); }); \ No newline at end of file diff --git a/test/ranking-history.test.ts b/test/ranking-history.test.ts index b8e4761..ace0db7 100644 --- a/test/ranking-history.test.ts +++ b/test/ranking-history.test.ts @@ -1,9 +1,19 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeAll, afterEach, afterAll, vi } from 'vitest'; import { formatRankingHistory } from '../src/ranking-history.js'; +import NarouAPI from '../src'; import { RankingType } from '../src/params.js'; import { parse } from 'date-fns'; +import { setupServer } from 'msw/node'; +import { http } from 'msw'; +import { responseGzipOrJson } from './mock'; + +const server = setupServer(); describe('formatRankingHistory', () => { + beforeAll(() => server.listen()); + afterEach(() => server.resetHandlers()); + afterAll(() => server.close()); + it('should format raw ranking history data correctly', () => { const input = { rtype: '20230101-d', @@ -71,4 +81,31 @@ describe('formatRankingHistory', () => { rank: 3 }); }); -}); \ No newline at end of file + + it('should pass execute options to ranking history request', async () => { + const mockFn = vi.fn(); + + server.use( + http.get('https://api.syosetu.com/rank/rankin/', ({ request }) => { + const url = new URL(request.url); + mockFn(request.headers.get('x-test')); + const response = [{ rtype: '20230101-d', pt: 100, rank: 5 }]; + return responseGzipOrJson(response, url); + }) + ); + + const result = await NarouAPI.rankingHistory('n0000a', { + fetchOptions: { headers: { 'x-test': 'hello' } } + }); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'd' as RankingType, + date: parse('20230101', 'yyyyMMdd', new Date()), + pt: 100, + rank: 5 + }); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith('hello'); + }); +}); diff --git a/test/ranking.test.ts b/test/ranking.test.ts index 8febe23..46fbda5 100644 --- a/test/ranking.test.ts +++ b/test/ranking.test.ts @@ -15,6 +15,19 @@ import { responseGzipOrJson } from "./mock"; const server = setupServer(); +const setupHeaderHandler = (mockFn: (...args: unknown[]) => void) => { + server.use( + http.get("https://api.syosetu.com/rank/rankget/", ({ request }) => { + const url = new URL(request.url); + mockFn(request.headers.get("x-test")); + const response: NarouRankingResult[] = [ + { ncode: "N0001AA", rank: 1, pt: 1000 }, + ]; + return responseGzipOrJson(response, url); + }) + ); +}; + describe("RankingBuilder", () => { beforeAll(() => { vi.useFakeTimers({ @@ -479,4 +492,71 @@ describe("RankingBuilder", () => { expect(result[0].title).toBe("タイトル"); }); }); + + describe("execute options", () => { + test("fetchOptionsがリクエストに渡される", async () => { + const mockFn = vi.fn(); + setupHeaderHandler(mockFn); + + const result = await ranking().execute({ + fetchOptions: { headers: { "x-test": "hello" } }, + }); + + expect(result).toHaveLength(1); + expect(result[0].ncode).toBe("N0001AA"); + expect(result[0].rank).toBe(1); + expect(result[0].pt).toBe(1000); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith("hello"); + }); + }); + + describe("executeWithFields options", async () => { + test("fetchOptionsがリクエストに渡される", async () => { + const mockRankingHeader = vi.fn(); + const mockNovelRequest = vi.fn(); + + server.use( + http.get("https://api.syosetu.com/rank/rankget/", ({ request }) => { + const url = new URL(request.url); + mockRankingHeader(request.headers.get("x-test")); + const response: NarouRankingResult[] = [ + { ncode: "N0001AA", rank: 1, pt: 1000 }, + ]; + return responseGzipOrJson(response, url); + }), + http.get("https://api.syosetu.com/novelapi/api/", ({ request }) => { + const url = new URL(request.url); + mockRankingHeader(request.headers.get("x-test")); + mockNovelRequest( + request.headers.get("x-test"), + url.searchParams.get("of"), + url.searchParams.size + ); + const response = [ + { allcount: 1 }, + { ncode: "N0001AA", title: "タイトル" }, + ]; + return responseGzipOrJson(response, url); + }) + ); + + const result = await ranking().executeWithFields(undefined, undefined, { + fetchOptions: { headers: { "x-test": "hello" } }, + }); + + expect(result).toHaveLength(1); + expect(result[0].ncode).toBe("N0001AA"); + expect(result[0].title).toBe("タイトル"); + expect(mockRankingHeader).toHaveBeenCalledTimes(2); + expect(mockRankingHeader).toHaveBeenNthCalledWith(1, "hello"); + expect(mockRankingHeader).toHaveBeenNthCalledWith(2, "hello"); + expect(mockNovelRequest).toHaveBeenCalledTimes(1); + expect(mockNovelRequest).toHaveBeenCalledWith( + "hello", + "", + 5 // ncode, gzip, out, of, lim + ); + }); + }); }); diff --git a/test/search-builder-r18.test.ts b/test/search-builder-r18.test.ts index a933936..7d31821 100644 --- a/test/search-builder-r18.test.ts +++ b/test/search-builder-r18.test.ts @@ -36,6 +36,19 @@ const setupMockHandler = ( ); }; +const setupHeaderHandler = (mockFn: (...args: unknown[]) => void) => { + server.use( + http.get("https://api.syosetu.com/novel18api/api/", ({ request }) => { + const url = new URL(request.url); + const response = [{ allcount: 1 }, { ncode: "N1234AB" }]; + + mockFn(request.headers.get("x-test")); + + return responseGzipOrJson(response, url); + }) + ); +}; + describe("SearchBuilderR18", () => { beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); @@ -99,6 +112,24 @@ describe("SearchBuilderR18", () => { }); }); + describe("execute options", () => { + test("fetchOptionsがリクエストに渡される", async () => { + const mockFn = vi.fn(); + setupHeaderHandler(mockFn); + + const result = await NarouAPI.searchR18().execute({ + fetchOptions: { headers: { "x-test": "hello" } }, + }); + + expect(result.allcount).toBe(1); + expect(result.length).toBe(1); + expect(result.values).toHaveLength(1); + expect(result.values[0].ncode).toBe("N1234AB"); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith("hello"); + }); + }); + describe("xid", () => { test("default", async () => { const mockFn = vi.fn(); @@ -214,4 +245,4 @@ describe("SearchBuilderR18", () => { expect(mockFn).toHaveBeenCalledWith("weekly", "json", 3); }); }); -}); \ No newline at end of file +}); diff --git a/test/search-builder.test.ts b/test/search-builder.test.ts index ca2b187..57a8deb 100644 --- a/test/search-builder.test.ts +++ b/test/search-builder.test.ts @@ -42,6 +42,19 @@ const setupMockHandler = ( ); }; +const setupHeaderHandler = (mockFn: (...args: unknown[]) => void) => { + server.use( + http.get("https://api.syosetu.com/novelapi/api/", ({ request }) => { + const url = new URL(request.url); + const response = [{ allcount: 1 }, { ncode: "N1234AB" }]; + + mockFn(request.headers.get("x-test")); + + return responseGzipOrJson(response, url); + }) + ); +}; + describe("SearchBuilder", () => { beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); @@ -115,6 +128,24 @@ describe("SearchBuilder", () => { }); }); + describe("execute options", () => { + test("fetchOptionsがリクエストに渡される", async () => { + const mockFn = vi.fn(); + setupHeaderHandler(mockFn); + + const result = await NarouAPI.search().execute({ + fetchOptions: { headers: { "x-test": "hello" } }, + }); + + expect(result.allcount).toBe(1); + expect(result.length).toBe(1); + expect(result.values).toHaveLength(1); + expect(result.values[0].ncode).toBe("N1234AB"); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith("hello"); + }); + }); + describe("start", () => { test("default", async () => { const mockFn = vi.fn(); diff --git a/test/user-search.test.ts b/test/user-search.test.ts index 783a882..5a538d5 100644 --- a/test/user-search.test.ts +++ b/test/user-search.test.ts @@ -35,6 +35,19 @@ const setupMockHandler = ( ); }; +const setupHeaderHandler = (mockFn: (...args: unknown[]) => void) => { + server.use( + http.get("https://api.syosetu.com/userapi/api/", ({ request }) => { + const url = new URL(request.url); + const response = [{ allcount: 1 }, { userid: 1234, name: "テストユーザー" }]; + + mockFn(request.headers.get("x-test")); + + return responseGzipOrJson(response, url); + }) + ); +}; + describe("UserSearchBuilder", () => { beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); @@ -80,6 +93,24 @@ describe("UserSearchBuilder", () => { }); }); + describe("execute options", () => { + test("fetchOptionsがリクエストに渡される", async () => { + const mockFn = vi.fn(); + setupHeaderHandler(mockFn); + + const result = await NarouAPI.searchUser().execute({ + fetchOptions: { headers: { "x-test": "hello" } }, + }); + + expect(result.allcount).toBe(1); + expect(result.length).toBe(1); + expect(result.values).toHaveLength(1); + expect(result.values[0].userid).toBe(1234); + expect(mockFn).toHaveBeenCalledTimes(1); + expect(mockFn).toHaveBeenCalledWith("hello"); + }); + }); + describe("limit", () => { test("default", async () => { const mockFn = vi.fn(); @@ -476,4 +507,4 @@ describe("UserSearchBuilder", () => { expect(mockFn).toHaveBeenCalledWith(`${UserFields.userid}-${UserFields.name}`, "5", "json", 3); }); }); -}); \ No newline at end of file +});