diff --git a/src/api/news.ts b/src/api/news.ts new file mode 100644 index 0000000..21b0204 --- /dev/null +++ b/src/api/news.ts @@ -0,0 +1,97 @@ +import { initUntypeable } from "untypeable"; +import { ValidArtist } from "./artists"; + +const u = initUntypeable().pushArg<"GET">(); +export const router = u.router({ + "/news/v1": { + GET: u.input<{ artist: ValidArtist }>().output(), + }, + + "/news/v1/feed": { + GET: u + .input() + .output>(), + }, + + "/news/v1/exclusive": { + GET: u + .input() + .output>(), + }, +}); + +export type NewsPayload = { + artist: ValidArtist; + start_after?: number; + limit?: number; +}; + +export type HomeNewsResult = { + sections: NewsSection[]; +}; + +export type NewsSectionBar = { + type: "bar"; + artist: ValidArtist; + contents: []; +}; + +export type NewsSectionBanner = { + type: "banner"; + artist: ValidArtist; + contents: NewsSectionBannerContent[]; +}; +export type NewsSectionBannerContent = { + id: number; + url: string; + createdAt: string; + label: "release" | "event" | "notice"; + order: number; + body: string; + imageUrl: string; +}; + +export type NewsSectionFeed = { + type: "feed"; + artist: ValidArtist; + title: string; + contents: NewsSectionFeedContent[]; +}; +export type NewsSectionFeedContent = { + id: number; + url: string; + createdAt: string; + artist: ValidArtist; + logoImageUrl: string; + body: string; + imageUrls: string[]; +}; + +export type NewsSectionExclusive = { + type: "exclusive"; + artist: ValidArtist; + title: string; + contents: NewsSectionExclusiveContent[]; +}; +export type NewsSectionExclusiveContent = { + id: number; + url: string; + createdAt: string; + title: string; + body: string; + thumbnailImageUrl: string; + nativeVideoUrl: string; +}; + +export type NewsSection = + | NewsSectionBar + | NewsSectionBanner + | NewsSectionFeed + | NewsSectionExclusive; + +export type NewsFeedResult = { + hasNext: boolean; + total: number; + nextStartAfter: string; + results: TPostType[]; +}; diff --git a/src/api/season.ts b/src/api/season.ts new file mode 100644 index 0000000..8a31b0a --- /dev/null +++ b/src/api/season.ts @@ -0,0 +1,33 @@ +import { initUntypeable } from "untypeable"; + +const u = initUntypeable().pushArg<"GET">(); +export const router = u.router({ + "/season/v2/:artist": { + GET: u.input<{ artist: string }>().output(), + }, +}); + +export type OngoingSeason = { + artist: string; + title: string; + image: string | null; + startDate: string; + endDate: null; + ongoing: true; +}; + +export type EndedSeason = { + artist: string; + title: string; + image: string | null; + startDate: string; + endDate: string; + ongoing: false; +}; + +export type Season = OngoingSeason | EndedSeason; + +export type SeasonResponse = { + seasons: Season[]; + currentSeason: OngoingSeason; +}; diff --git a/src/api/types.ts b/src/api/types.ts deleted file mode 100644 index 47b9622..0000000 --- a/src/api/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { ValidArtist, Artist, Member, ArtistWithMembers } from "./artists"; -export { User, SearchResponse, SearchResult } from "./user"; -export { - LoginPayload, - LoginResult, - RefreshPayload, - RefreshResult, -} from "./auth"; diff --git a/src/cosmo.ts b/src/cosmo.ts index 5a18114..c0b9eeb 100644 --- a/src/cosmo.ts +++ b/src/cosmo.ts @@ -45,4 +45,34 @@ export class Cosmo implements CosmoContract { async refreshToken(payload: RefreshPayload) { return await this.client("/auth/v1/refresh", "POST", payload); } + + async getHomeNews(artist: ValidArtist) { + return await this.client("/news/v1", "GET", { artist }); + } + + async getAtmosphereFeed( + artist: ValidArtist, + options?: { startAfter?: number; limit?: number } + ) { + return await this.client("/news/v1/feed", "GET", { + artist, + start_after: options?.startAfter, + limit: options?.limit, + }); + } + + async getExclusiveFeed( + artist: ValidArtist, + options?: { startAfter?: number; limit?: number } + ) { + return await this.client("/news/v1/exclusive", "GET", { + artist, + start_after: options?.startAfter, + limit: options?.limit, + }); + } + + async getSeasons(artist: ValidArtist) { + return await this.client("/season/v2/:artist", "GET", { artist }); + } } diff --git a/src/index.ts b/src/index.ts index 48e81e2..6e36e56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,2 @@ export { Cosmo } from "./cosmo"; -export * from "./api/types"; +export * from "./types"; diff --git a/src/interface.ts b/src/interface.ts index f19a9c0..7e3d2e9 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,4 +1,4 @@ -import { Artist, ArtistWithMembers } from "./api/artists"; +import { Artist, ArtistWithMembers, ValidArtist } from "./api/artists"; import { LoginPayload, LoginResult, @@ -6,6 +6,13 @@ import { RefreshResult, } from "./api/auth"; import { SearchResult, User } from "./api/user"; +import { + HomeNewsResult, + NewsFeedResult, + NewsSectionExclusiveContent, + NewsSectionFeedContent, + SeasonResponse, +} from "./types"; export interface CosmoContract { /** @@ -16,7 +23,7 @@ export interface CosmoContract { /** * Fetch a single artist and its members. */ - getArtist(artist: string): Promise; + getArtist(artist: ValidArtist): Promise; /** * Fetch the currently authenticated user. @@ -37,4 +44,30 @@ export interface CosmoContract { * Log into Cosmo using an email and Ramper access token. */ refreshToken(payload: RefreshPayload): Promise; + + /** + * Fetch news on the home page for the given artist. + */ + getHomeNews(artist: ValidArtist): Promise; + + /** + * Fetch the news feed for the given artist. + */ + getAtmosphereFeed( + artist: ValidArtist, + options?: { startAfter?: number; limit?: number } + ): Promise>; + + /** + * Fetch the exclusive feed for the given artist. + */ + getExclusiveFeed( + artist: ValidArtist, + options?: { startAfter?: number; limit?: number } + ): Promise>; + + /** + * Get the past and current seasons for the given artist. + */ + getSeasons(artist: ValidArtist): Promise; } diff --git a/src/router.ts b/src/router.ts index f74f11e..950223a 100644 --- a/src/router.ts +++ b/src/router.ts @@ -3,15 +3,22 @@ import { CosmoUnauthenticatedError, HTTPError } from "./error"; import { router as artistRouter } from "./api/artists"; import { router as userRouter } from "./api/user"; import { router as authRouter } from "./api/auth"; +import { router as newsRouter } from "./api/news"; +import { router as seasonRouter } from "./api/season"; export type FetcherOptions = { accessToken?: string; maxRetries?: number; }; -export const router = artistRouter.merge(userRouter).merge(authRouter); const COSMO_ENDPOINT = "https://api.cosmo.fans"; +export const router = artistRouter + .merge(userRouter) + .merge(authRouter) + .merge(newsRouter) + .merge(seasonRouter); + export function createDefaultFetcher(options: FetcherOptions = {}) { return (path: string, method: "GET" | "POST", input = {}) => { const pathWithParams = path.replace( diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..3db3660 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,27 @@ +export { FetcherOptions } from "./router"; +export { ValidArtist, Artist, Member, ArtistWithMembers } from "./api/artists"; +export { User, SearchResponse, SearchResult } from "./api/user"; +export { + LoginPayload, + LoginResult, + RefreshPayload, + RefreshResult, +} from "./api/auth"; +export { + HomeNewsResult, + NewsFeedResult, + NewsSection, + NewsSectionBanner, + NewsSectionBannerContent, + NewsSectionBar, + NewsSectionExclusive, + NewsSectionExclusiveContent, + NewsSectionFeed, + NewsSectionFeedContent, +} from "./api/news"; +export { + OngoingSeason, + EndedSeason, + Season, + SeasonResponse, +} from "./api/season"; diff --git a/tests/mocks.ts b/tests/mocks.ts index f9722d3..e69a6ae 100644 --- a/tests/mocks.ts +++ b/tests/mocks.ts @@ -17,6 +17,16 @@ export const handlers = [ http.post(cosmo("/auth/v1/refresh"), () => HttpResponse.json(json.refreshToken) ), + + // news + http.get(cosmo("/news/v1"), () => HttpResponse.json(json.newsHome)), + http.get(cosmo("/news/v1/feed"), () => HttpResponse.json(json.newsFeed)), + http.get(cosmo("/news/v1/exclusive"), () => + HttpResponse.json(json.newsExclusive) + ), + + // season + http.get(cosmo("/season/v2/*"), () => HttpResponse.json(json.getSeason)), ]; // conditional handlers @@ -172,4 +182,145 @@ export const json = { refreshToken: "refreshToken", }, }, + + newsHome: {}, + + newsFeed: { + hasNext: true, + total: 5, + nextStartAfter: "3", + results: [ + { + id: 317, + url: "https://www.youtube.com/@official_artms", + createdAt: "2023-11-06T08:51:38.421Z", + artist: "ARTMS", + logoImageUrl: "https://static.cosmo.fans/assets/artms-logo.png", + body: "On Set of Princess HeeJin's MV (*˘◡˘*)", + imageUrls: [ + "https://static.cosmo.fans/admin/uploads/48913c70-03ee-4785-ac0d-40455bedb681.jpg", + "https://static.cosmo.fans/admin/uploads/6d912cf0-a2af-49d7-a3d7-9d5dfadbd969.jpg", + "https://static.cosmo.fans/admin/uploads/46fe3874-dd54-4398-9366-6506c1beb8b8.jpg", + "https://static.cosmo.fans/admin/uploads/196c9e6e-77b1-4bb4-9e98-8490bf1351c9.jpg", + ], + }, + { + id: 295, + url: "https://www.youtube.com/@official_artms", + createdAt: "2023-09-06T08:58:04.407Z", + artist: "ARTMS", + logoImageUrl: "https://static.cosmo.fans/assets/artms-logo.png", + body: "ODD EYE CIRCLE's whereabouts", + imageUrls: [ + "https://static.cosmo.fans/admin/uploads/66987575-ea19-4d1b-b0e6-d0adc7b9425c.jpg", + "https://static.cosmo.fans/admin/uploads/7e29b0e7-eaaf-4966-8791-e2065820df63.jpg", + "https://static.cosmo.fans/admin/uploads/a8e24b8d-320f-417e-850d-35b325ede8f5.jpg", + "https://static.cosmo.fans/admin/uploads/62bfd022-d4a0-4c3e-9850-20a181456fef.jpg", + ], + }, + { + id: 238, + url: "", + createdAt: "2023-07-11T01:07:32.559Z", + artist: "ARTMS", + logoImageUrl: "https://static.cosmo.fans/assets/artms-logo.png", + body: "", + imageUrls: [ + "https://static.cosmo.fans/images/sigma-prod/artms-20230712/feed-1.jpg", + "https://static.cosmo.fans/images/sigma-prod/artms-20230712/feed-2.jpg", + "https://static.cosmo.fans/images/sigma-prod/artms-20230712/feed-3.jpg", + "https://static.cosmo.fans/images/sigma-prod/artms-20230712/feed-4.jpg", + ], + }, + ], + }, + + newsExclusive: { + hasNext: false, + total: 6, + results: [ + { + id: 36, + url: "https://youtu.be/4uVqzLh1HiE", + createdAt: "2023-10-23T03:18:18.595Z", + title: "[Teaser] HeeJin 'Algorithm' (MV Ver.)", + body: "#ARTMS #HeeJin #희진 #K #Algorithm", + thumbnailImageUrl: + "https://static.cosmo.fans/admin/uploads/86763077-0431-41af-af8f-aae1676402e8.jpg", + nativeVideoUrl: "", + }, + { + id: 35, + url: "https://youtu.be/Lv9O9gLrnzc", + createdAt: "2023-10-11T13:43:40.316Z", + title: "[Teaser] HeeJin 'Algorithm' (K Ver.)", + body: "#ARTMS #HeeJin #희진 #K #Algorithm", + thumbnailImageUrl: + "https://static.cosmo.fans/admin/uploads/557c2fde-38df-44d1-ba44-bdcbe7c70c67.jpeg", + nativeVideoUrl: null, + }, + { + id: 31, + url: "https://static.cosmo.fans/pages/update-notice.html", + createdAt: "2023-10-03T04:00:00.000Z", + title: "OEC Europe Tour Selfcam Collection", + body: "Warning! Delicious food and hunger!", + thumbnailImageUrl: + "https://static.cosmo.fans/admin/uploads/b62980ea-e4c1-4b66-b8cd-1b36db3743bc.jpeg", + nativeVideoUrl: + "https://customer-odzj4xy9rztfqeuh.cloudflarestream.com/b5d789e48acfe04ff163b19872c32458/manifest/video.m3u8", + }, + { + id: 5, + url: "https://youtu.be/UDxID0_A9x4", + createdAt: "2023-07-11T01:09:25.477Z", + title: "ODD EYE CIRCLE ‘Air Force One' MV | ARTMS", + body: "#KimLip #JinSoul #Choerry #Air Force One", + thumbnailImageUrl: + "https://static.cosmo.fans/uploads/assets/production/odd-eye-circle-mv-thumnail.jpg", + nativeVideoUrl: "", + }, + { + id: 6, + url: "https://youtu.be/FKo0tjVeAEU", + createdAt: "2023-07-03T05:48:53.128Z", + title: "[Teaser] ODD EYE CIRCLE ‘Air Force One'", + body: "#KimLip #JinSoul #Choerry #VersionUp", + thumbnailImageUrl: + "https://static.cosmo.fans/images/sigma-prod/artms-20230704/youtube.jpg", + nativeVideoUrl: "", + }, + { + id: 8, + url: "https://youtu.be/7aFU-Ick8go", + createdAt: "2023-06-17T01:10:36.157Z", + title: "ARTMS : The First Step", + body: "Welcome to Cosmo ARTMS! ", + thumbnailImageUrl: + "https://s3.ap-northeast-2.amazonaws.com/static.cosmo.fans/uploads/assets/development/31373e4c-c766-42ab-a2f0-531d205c4dd9.jpeg", + nativeVideoUrl: "", + }, + ], + }, + + getSeason: { + seasons: [ + { + artist: "artms", + title: "Atom01", + image: null, + startDate: "2023-06-18T00:00:00.000Z", + endDate: null, + ongoing: true, + }, + ], + currentSeason: { + artist: "artms", + title: "Atom01", + image: null, + startDate: "2023-06-18T00:00:00.000Z", + endDate: null, + ongoing: true, + }, + }, }; diff --git a/tests/news.spec.ts b/tests/news.spec.ts new file mode 100644 index 0000000..ce4c921 --- /dev/null +++ b/tests/news.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { Cosmo } from "../src"; +import { json } from "./mocks"; + +test("fetches home page news", async () => { + const cosmo = new Cosmo(); + const result = await cosmo.getHomeNews("artms"); + expect(result).toEqual(json.newsHome); +}); + +test("fetches atmosphere feed", async () => { + const cosmo = new Cosmo(); + const result = await cosmo.getAtmosphereFeed("artms"); + expect(result).toEqual(json.newsFeed); +}); + +test("fetches exclusive feed", async () => { + const cosmo = new Cosmo(); + const result = await cosmo.getExclusiveFeed("artms"); + expect(result).toEqual(json.newsExclusive); +}); diff --git a/tests/season.spec.ts b/tests/season.spec.ts new file mode 100644 index 0000000..3f75529 --- /dev/null +++ b/tests/season.spec.ts @@ -0,0 +1,9 @@ +import { expect, test } from "vitest"; +import { Cosmo } from "../src"; +import { json } from "./mocks"; + +test("fetches seasons", async () => { + const cosmo = new Cosmo(); + const result = await cosmo.getSeasons("artms"); + expect(result).toEqual(json.getSeason); +});