wescopeland/retroachievements-js

View on GitHub
src/retro-achievements-client.ts

Summary

Maintainability
D
1 day
Test Coverage
A
91%
import urlcat from 'urlcat';
import fetch from 'isomorphic-unfetch';
import camelcaseKeys from 'camelcase-keys';

import * as fromModels from './models';
import { sanitizeProps } from './util/sanitizeProps';

export class RetroAchievementsClient {
  private baseUrl = 'https://retroachievements.org/API';
  private apiKey: string;
  private userName: string;

  constructor(options: { userName: string; apiKey: string }) {
    this.apiKey = options.apiKey;
    this.userName = options.userName;
  }

  async getConsoleIds(): Promise<fromModels.ConsoleId[]> {
    const requestUrl = urlcat(this.baseUrl, 'API_GetConsoleIDs.php', {
      ...this.buildAuthParameters()
    });

    try {
      const responseBody = await this.loadResponseBody<
        fromModels.ApiConsoleId[]
      >(requestUrl);

      return camelcaseKeys(
        sanitizeProps(responseBody)
      ) as fromModels.ConsoleId[];
    } catch (err) {
      throw new Error(
        `RetroAchievements API: There was a problem retrieving the console IDs. ${err}`
      );
    }
  }

  async getUserRankAndScore(
    userName: string
  ): Promise<fromModels.UserRankAndScore> {
    const requestUrl = urlcat(this.baseUrl, 'API_GetUserRankAndScore.php', {
      ...this.buildAuthParameters(),
      u: userName
    });

    try {
      const responseBody = await this.loadResponseBody<
        fromModels.ApiUserRankAndScore
      >(requestUrl);

      if (responseBody.Score === null) {
        throw new Error(
          `RetroAchievements API: User ${userName} was not found.`
        );
      }

      return camelcaseKeys(sanitizeProps(responseBody));
    } catch (err) {
      throw new Error(
        `RetroAchievements API: There was a problem retrieving the rank and score for user ${userName}. ${err}`
      );
    }
  }

  async getGameInfoByGameId(gameId: number): Promise<fromModels.GameInfo> {
    const requestUrl = urlcat(this.baseUrl, 'API_GetGame.php', {
      ...this.buildAuthParameters(),
      i: gameId
    });

    try {
      const responseBody = await this.loadResponseBody<fromModels.ApiGameInfo>(
        requestUrl
      );

      if (responseBody.GameTitle === 'UNRECOGNISED') {
        throw 'Game not found';
      }

      return this.sanitizeApiGameInfo(responseBody) as fromModels.GameInfo;
    } catch (err) {
      throw new Error(
        `RetroAchievements API: There was a problem retrieving the game info for ID ${gameId}. ${err}`
      );
    }
  }

  async getExtendedGameInfoByGameId(
    gameId: number
  ): Promise<fromModels.GameInfoExtended> {
    const requestUrl = urlcat(this.baseUrl, 'API_GetGameExtended.php', {
      ...this.buildAuthParameters(),
      i: gameId
    });

    try {
      const responseBody = await this.loadResponseBody<
        fromModels.ApiGameInfoExtended
      >(requestUrl);

      if (responseBody.ID === undefined) {
        throw 'Game not found';
      }

      return {
        ...this.sanitizeApiGameInfo(responseBody),
        achievements: this.sanitizeAchievements(responseBody.Achievements),
        richPresencePatch: responseBody.RichPresencePatch,
        id: responseBody.ID ? Number(responseBody.ID) : undefined,
        isFinal: responseBody?.IsFinal,
        numAchievements: responseBody.NumAchievements
          ? Number(responseBody.NumAchievements)
          : undefined,
        numDistinctPlayersCasual: responseBody.NumDistinctPlayersCasual
          ? Number(responseBody.NumDistinctPlayersCasual)
          : undefined,
        numDistinctPlayersHardcore: responseBody.NumDistinctPlayersHardcore
          ? Number(responseBody.NumDistinctPlayersHardcore)
          : undefined
      };
    } catch (err) {
      throw new Error(
        `RetroAchievements API: There was a problem retrieving the extended game info for ID ${gameId}. ${err}`
      );
    }
  }

  async getGameListByConsoleId(
    consoleId: number
  ): Promise<fromModels.GameListEntity[]> {
    const requestUrl = urlcat(this.baseUrl, 'API_GetGameList.php', {
      ...this.buildAuthParameters(),
      i: consoleId
    });

    try {
      const responseBody = await this.loadResponseBody<
        fromModels.ApiGameListEntity[]
      >(requestUrl);

      return camelcaseKeys(sanitizeProps(responseBody));
    } catch (err) {
      throw new Error(
        `RetroAchievements API: There was a problem retrieving the game list for console ID ${consoleId}. ${err}`
      );
    }
  }

  async getTopTenUsers(): Promise<fromModels.TopTenUser[]> {
    const requestUrl = urlcat(this.baseUrl, 'API_GetTopTenUsers.php', {
      ...this.buildAuthParameters()
    });

    try {
      const responseBody = await this.loadResponseBody<
        fromModels.ApiTopTenUser[]
      >(requestUrl);

      return responseBody.map(apiUser => ({
        userName: apiUser['1'],
        points: Number(apiUser['2']),
        retroRatioPoints: Number(apiUser['3'])
      }));
    } catch (err) {
      throw new Error(
        `RetroAchievements API: There was a problem retrieving the top ten users. ${err}`
      );
    }
  }

  async getUserProgressForGames(
    userName: string,
    gameIds: number[]
  ): Promise<fromModels.UserProgressForGame[]> {
    const requestUrl = urlcat(this.baseUrl, 'API_GetUserProgress.php', {
      ...this.buildAuthParameters(),
      u: userName,
      i: gameIds.join(', ')
    });

    try {
      const responseBody = await this.loadResponseBody<{
        [key: string]: fromModels.ApiUserProgressForGame;
      }>(requestUrl);

      const progressItems: fromModels.UserProgressForGame[] = [];

      for (const [key, value] of Object.entries(responseBody)) {
        // A key of 0 means the game couldn't be found in the RetroAchievements system.
        if (key !== '0') {
          progressItems.push({
            ...camelcaseKeys(sanitizeProps(value)),
            gameId: Number(key)
          });
        }
      }

      return progressItems;
    } catch (err) {
      throw new Error(
        `RetroAchievements API: There was a problem retrieving the progress for games ${gameIds.join(
          ', '
        )}. ${err}`
      );
    }
  }

  async getUserRecentlyPlayedGames(
    userName: string,
    count?: number
  ): Promise<fromModels.UserRecentlyPlayedGame[]> {
    const requestUrl = urlcat(
      this.baseUrl,
      'API_GetUserRecentlyPlayedGames.php',
      {
        ...this.buildAuthParameters(),
        u: userName,
        c: count
      }
    );

    try {
      const responseBody = await this.loadResponseBody<
        fromModels.ApiUserRecentlyPlayedGame[]
      >(requestUrl);

      return camelcaseKeys(sanitizeProps(responseBody));
    } catch (err) {
      throw new Error(
        `RetroAchievements API: There was a problem retrieving the recently played games for user ${userName}. ${err}`
      );
    }
  }

  async getUserGameCompletionStats(
    userName: string
  ): Promise<fromModels.UserGameCompletion[]> {
    const requestUrl = urlcat(this.baseUrl, 'API_GetUserCompletedGames.php', {
      ...this.buildAuthParameters(),
      u: userName
    });

    try {
      const responseBody = await this.loadResponseBody<
        fromModels.ApiUserGameCompletion[]
      >(requestUrl);

      return camelcaseKeys(
        sanitizeProps(responseBody)
      ) as fromModels.UserGameCompletion[];
    } catch (err) {
      throw new Error(
        `RetroAchievements API: There was a problem retrieving the game completion stats for user ${userName}. ${err}`
      );
    }
  }

  async getUserPoints(userName: string): Promise<fromModels.UserPoints> {
    const requestUrl = urlcat(this.baseUrl, 'API_GetUserPoints.php', {
      ...this.buildAuthParameters(),
      u: userName
    });

    try {
      const responseBody = await this.loadResponseBody<
        fromModels.ApiUserPoints
      >(requestUrl);

      return camelcaseKeys(
        sanitizeProps(responseBody)
      ) as fromModels.UserPoints;
    } catch (err) {
      throw new Error(
        `RetroAchievements API: There was a problem retrieving the points stats for user ${userName}. ${err}`
      );
    }
  }

  async getAchievementDistributionForGameId(
    gameId: number,
    isHardcoreOnly: boolean
  ): Promise<Record<string, number>> {
    const requestUrl = urlcat(
      this.baseUrl,
      'API_GetAchievementDistribution.php',
      {
        ...this.buildAuthParameters(),
        i: gameId,
        h: isHardcoreOnly ? 1 : 0
      }
    );

    return await this.loadResponseBody<Record<string, number>>(requestUrl);
  }

  async getUserAchievementsEarnedBetweenDates(
    userName: string,
    dateFrom: Date,
    dateTo: Date
  ): Promise<fromModels.DatedAchievement[]> {
    const requestUrl = urlcat(
      this.baseUrl,
      'API_GetAchievementsEarnedBetween.php',
      {
        ...this.buildAuthParameters(),
        u: userName,
        f: (dateFrom.getTime() / 1000).toFixed(0),
        t: (dateTo.getTime() / 1000).toFixed(0)
      }
    );

    try {
      const responseBody = await this.loadResponseBody<
        fromModels.ApiDatedAchievement[]
      >(requestUrl);

      return camelcaseKeys(
        sanitizeProps(responseBody)
      ) as fromModels.DatedAchievement[];
    } catch (err) {
      throw new Error(
        `RetroAchievements API: There was a problem retrieving achievements for user ${userName}. ${err}`
      );
    }
  }

  async getUserAchievementsEarnedOnDate(
    userName: string,
    date: Date
  ): Promise<fromModels.DatedAchievement[]> {
    const requestUrl = urlcat(
      this.baseUrl,
      'API_GetAchievementsEarnedOnDay.php',
      {
        ...this.buildAuthParameters(),
        u: userName,
        d: `${date.getUTCFullYear()}-${date.getUTCMonth()}-${date.getUTCDate()}`
      }
    );

    try {
      const responseBody = await this.loadResponseBody<
        fromModels.ApiDatedAchievement[]
      >(requestUrl);

      return camelcaseKeys(
        sanitizeProps(responseBody)
      ) as fromModels.DatedAchievement[];
    } catch (err) {
      throw new Error(
        `RetroAchievements API: There was a problem retrieving achievements for user ${userName}. ${err}`
      );
    }
  }

  async getUserProgressForGameId(
    userName: string,
    gameId: number
  ): Promise<fromModels.GameInfoAndUserProgress> {
    const requestUrl = urlcat(
      this.baseUrl,
      'API_GetGameInfoAndUserProgress.php',
      {
        ...this.buildAuthParameters(),
        u: userName,
        g: gameId
      }
    );

    try {
      const responseBody = await this.loadResponseBody<
        fromModels.ApiGameInfoAndUserProgress
      >(requestUrl);

      const sanitizedAchievements = this.sanitizeAchievements(
        responseBody.Achievements
      );

      const modifiedResponse: Partial<fromModels.ApiGameInfoAndUserProgress> = {
        ...responseBody
      };

      delete modifiedResponse.Achievements;

      modifiedResponse.UserCompletion = modifiedResponse.UserCompletion?.replace(
        '%',
        ''
      );

      modifiedResponse.UserCompletionHardcore = modifiedResponse.UserCompletionHardcore?.replace(
        '%',
        ''
      );

      const sanitizedResponse = camelcaseKeys(sanitizeProps(modifiedResponse));
      sanitizedResponse.achievements = sanitizedAchievements;

      return sanitizedResponse;
    } catch (err) {
      throw new Error(
        `RetroAchievements API: There was a problem retrieving game progress for user ${userName} on game id ${gameId}. ${err}`
      );
    }
  }

  async getUserSummary(userName: string): Promise<fromModels.UserSummary> {
    const requestUrl = urlcat(this.baseUrl, 'API_GetUserSummary.php', {
      ...this.buildAuthParameters(),
      u: userName,
      g: 0,
      a: 0
    });

    try {
      const responseBody = await this.loadResponseBody<
        fromModels.ApiUserSummary
      >(requestUrl);

      const sanitizedRecentlyPlayed = camelcaseKeys(
        sanitizeProps(responseBody.RecentlyPlayed)
      );

      const sanitizedAwarded = this.sanitizeAwarded(responseBody.Awarded);

      const sanitizedRecentAchievements = this.sanitizeRecentAchievements(
        responseBody.RecentAchievements
      );

      const modifiedResponse: Partial<fromModels.ApiUserSummary> = {
        ...responseBody
      };

      delete modifiedResponse.RecentlyPlayed;
      delete modifiedResponse.Awarded;
      delete modifiedResponse.RecentAchievements;

      const sanitizedResponse = {
        ...camelcaseKeys(sanitizeProps(modifiedResponse)),
        recentlyPlayed: sanitizedRecentlyPlayed,
        recentAchievements: sanitizedRecentAchievements,
        awarded: sanitizedAwarded
      };

      return sanitizedResponse;
    } catch (err) {
      throw new Error(
        `RetroAchievements API: There was a problem retrieving the user summary for user ${userName}. ${err}`
      );
    }
  }

  private buildAuthParameters() {
    return {
      z: this.userName,
      y: this.apiKey
    };
  }

  private async loadResponseBody<T>(requestUrl: string): Promise<T> {
    const httpResponse = await fetch(requestUrl);
    const responseBody = (await httpResponse.json()) as T;

    return responseBody;
  }

  private sanitizeAchievements(
    apiAchievements:
      | any[]
      | {
          [name: string]: fromModels.ApiAchievement;
        }
  ) {
    const achievements: fromModels.Achievement[] = [];

    for (const [_, apiAchievement] of Object.entries(apiAchievements)) {
      achievements.push(camelcaseKeys(sanitizeProps(apiAchievement)));
    }

    return achievements;
  }

  private sanitizeAwarded(apiAwardedList: {
    [gameId: string]: fromModels.ApiUserProgressForGame;
  }) {
    const awarded: fromModels.UserProgressForGame[] = [];

    for (const [gameId, apiAwarded] of Object.entries(apiAwardedList)) {
      awarded.push({
        gameId: Number(gameId),
        ...camelcaseKeys(sanitizeProps(apiAwarded))
      });
    }

    return awarded;
  }

  private sanitizeApiGameInfo(
    apiGame: fromModels.ApiGameInfo | fromModels.ApiGameInfoExtended
  ) {
    return camelcaseKeys(sanitizeProps(apiGame));
  }

  private sanitizeRecentAchievements(apiRecentAchievements: {
    [gameId: string]: {
      [achievementId: string]: fromModels.ApiAchievement;
    };
  }) {
    const recentAchievements: fromModels.Achievement[] = [];

    for (const [_, apiGameAchievements] of Object.entries(
      apiRecentAchievements
    )) {
      for (const [_, apiAchievement] of Object.entries(apiGameAchievements)) {
        recentAchievements.push(camelcaseKeys(sanitizeProps(apiAchievement)));
      }
    }

    return recentAchievements;
  }
}