refactor(frontend): some degree of api wrapping
This commit is contained in:
		
							parent
							
								
									f4a63fc95e
								
							
						
					
					
						commit
						ba24815320
					
				
					 11 changed files with 365 additions and 222 deletions
				
			
		|  | @ -1,28 +1,36 @@ | |||
| /** An array returned by the API. (Can be `null` due to a quirk in Go.) */ | ||||
| export type Arr<T> = T[] | null; | ||||
| 
 | ||||
| export interface PartialPerson { | ||||
|   id: string; | ||||
|   name: string; | ||||
|   display_name: string | null; | ||||
|   avatar_urls: string[] | null; | ||||
|   avatar_urls: Arr<string>; | ||||
| } | ||||
| 
 | ||||
| export type PartialUser = PartialPerson; | ||||
| 
 | ||||
| export type PartialMember = PartialPerson; | ||||
| 
 | ||||
| interface _Person extends PartialPerson { | ||||
| /** The shared interface of `Member` and `User`. | ||||
|  * A typical `_Person` is only one of those two, so consider using `Person` instead. | ||||
|  */ | ||||
| export interface _Person extends PartialPerson { | ||||
|   bio: string | null; | ||||
|   links: string[] | null; | ||||
|   names: Name[] | null; | ||||
|   pronouns: Pronoun[] | null; | ||||
|   fields: Field[] | null; | ||||
|   links: Arr<string>; | ||||
|   names: Arr<Name>; | ||||
|   pronouns: Arr<Pronoun>; | ||||
|   fields: Arr<Field>; | ||||
| } | ||||
| 
 | ||||
| export interface User extends _Person { | ||||
|   members: Arr<PartialMember>; | ||||
| } | ||||
| 
 | ||||
| export interface Member extends _Person { | ||||
|   user: PartialUser; | ||||
| } | ||||
| 
 | ||||
| export interface User extends _Person { | ||||
|   members: PartialMember[] | null; | ||||
| } | ||||
| 
 | ||||
| export type Person = Member | User; | ||||
| 
 | ||||
| export interface MeUser extends User { | ||||
|  | @ -43,11 +51,11 @@ export interface Pronoun { | |||
| 
 | ||||
| export interface Field { | ||||
|   name: string; | ||||
|   favourite: string[] | null; | ||||
|   okay: string[] | null; | ||||
|   jokingly: string[] | null; | ||||
|   friends_only: string[] | null; | ||||
|   avoid: string[] | null; | ||||
|   favourite: Arr<string>; | ||||
|   okay: Arr<string>; | ||||
|   jokingly: Arr<string>; | ||||
|   friends_only: Arr<string>; | ||||
|   avoid: Arr<string>; | ||||
| } | ||||
| 
 | ||||
| export interface APIError { | ||||
|  | @ -101,3 +109,28 @@ export interface SignupResponse { | |||
|   user: MeUser; | ||||
|   token: string; | ||||
| } | ||||
| 
 | ||||
| const apiBase = process.env.API_BASE ?? "/api"; | ||||
| 
 | ||||
| export async function fetchAPI<T>( | ||||
|   path: string, | ||||
|   method = "GET", | ||||
|   body: any = null | ||||
| ) { | ||||
|   const token = | ||||
|     typeof localStorage !== "undefined" && | ||||
|     localStorage.getItem("pronouns-token"); | ||||
| 
 | ||||
|   const resp = await fetch(`${apiBase}/v1${path}`, { | ||||
|     method, | ||||
|     headers: { | ||||
|       ...token ? { Authorization: token } : {}, | ||||
|       "Content-Type": "application/json", | ||||
|     }, | ||||
|     body: body ? JSON.stringify(body) : null, | ||||
|   }); | ||||
| 
 | ||||
|   const data = await resp.json(); | ||||
|   if (resp.status < 200 || resp.status >= 300) throw data as APIError; | ||||
|   return data as T; | ||||
| } | ||||
							
								
								
									
										209
									
								
								frontend/lib/api.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										209
									
								
								frontend/lib/api.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,209 @@ | |||
| import * as API from "./api-fetch"; | ||||
| import { fetchAPI } from './api-fetch'; | ||||
| 
 | ||||
| function getDomain(): string { | ||||
|   const domain = typeof window !== "undefined" ? window.location.origin : process.env.DOMAIN; | ||||
|   if (!domain) throw new Error('process.env.DOMAIN not set'); | ||||
|   return domain; | ||||
| } | ||||
| 
 | ||||
| export class PartialPerson { | ||||
|   id: string; | ||||
|   name: string; | ||||
|   displayName: string | null; | ||||
|   avatarUrls: string[]; | ||||
|   constructor({ id, name, display_name, avatar_urls }: API.PartialPerson) { | ||||
|     this.id = id; | ||||
|     this.name = name; | ||||
|     this.displayName = display_name; | ||||
|     this.avatarUrls = avatar_urls ?? []; | ||||
|   } | ||||
| 
 | ||||
|   display(): string { | ||||
|     return this.displayName ?? this.name; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export class PartialUser extends PartialPerson {} | ||||
| 
 | ||||
| export class PartialMember extends PartialPerson {} | ||||
| 
 | ||||
| abstract class _Person extends PartialPerson { | ||||
|   bio: string | null; | ||||
|   links: string[]; | ||||
|   names: Name[]; | ||||
|   pronouns: Pronouns[]; | ||||
|   fields: Field[]; | ||||
|   constructor(apiData: API._Person) { | ||||
|     super(apiData); | ||||
|     const { bio, links, names, pronouns, fields } = apiData; | ||||
|     this.bio = bio; | ||||
|     this.links = links ?? []; | ||||
|     this.names = (names ?? []).map(x => new Name(x)); | ||||
|     this.pronouns = (pronouns ?? []).map(x => new Pronouns(x)); | ||||
|     this.fields = (fields ?? []).map(x => new Field(x)); | ||||
|   } | ||||
| 
 | ||||
|   abstract fullHandle(): string | ||||
| 
 | ||||
|   shortHandle(): string { | ||||
|     return this.fullHandle(); | ||||
|   } | ||||
| 
 | ||||
|   abstract relativeURL(): string | ||||
| 
 | ||||
|   absoluteURL(): string { | ||||
|     return `${getDomain()}${this.relativeURL()}`; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export class User extends _Person { | ||||
|   partialMembers: PartialMember[]; | ||||
|   constructor(apiData: API.User) { | ||||
|     super(apiData); | ||||
|     const { members } = apiData; | ||||
|     this.partialMembers = (members ?? []).map(x => new PartialMember(x)); | ||||
|   } | ||||
| 
 | ||||
|   static async fetchFromName(name: string): Promise<User> { | ||||
|     return new User(await fetchAPI<API.User>(`/users/${name}`)); | ||||
|   } | ||||
| 
 | ||||
|   fullHandle(): string { | ||||
|     return `@${this.name}`; | ||||
|   } | ||||
| 
 | ||||
|   shortHandle(): string { | ||||
|     return this.fullHandle(); | ||||
|   } | ||||
| 
 | ||||
|   relativeURL(): string { | ||||
|     return `/u/${this.name}`; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export class Member extends _Person { | ||||
|   partialUser: PartialUser; | ||||
|   constructor(apiData: API.Member) { | ||||
|     super(apiData); | ||||
|     const { user } = apiData; | ||||
|     this.partialUser = new PartialUser(user); | ||||
|   } | ||||
| 
 | ||||
|   static async fetchFromUserAndMemberName(userName: string, memberName: string): Promise<Member> { | ||||
|     return new Member(await fetchAPI<API.Member>(`/users/${userName}/members/${memberName}`)); | ||||
|   } | ||||
| 
 | ||||
|   fullHandle(): string { | ||||
|     return `${this.name}@${this.partialUser.name}`; | ||||
|   } | ||||
| 
 | ||||
|   relativeURL(): string { | ||||
|     return `/u/${this.partialUser.name}/${this.name}`; | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export type Person = Member | User; | ||||
| 
 | ||||
| export class MeUser extends User { | ||||
|   discord: string | null; | ||||
|   discordUsername: string | null; | ||||
|   constructor(apiData: API.MeUser) { | ||||
|     super(apiData); | ||||
|     const { discord, discord_username } = apiData; | ||||
|     this.discord = discord; | ||||
|     this.discordUsername = discord_username; | ||||
|   } | ||||
| 
 | ||||
|   static async fetchMe(): Promise<MeUser> { | ||||
|     return new MeUser(await fetchAPI<API.MeUser>("/users/@me")); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export enum LabelType { | ||||
|   Name = 1, | ||||
|   Pronouns = 2, | ||||
|   Unspecified = 3, | ||||
| } | ||||
| 
 | ||||
| export const LabelStatus = API.WordStatus; | ||||
| export type LabelStatus = API.WordStatus; | ||||
| 
 | ||||
| export interface LabelData { | ||||
|   type?: LabelType | ||||
|   displayText: string | null | ||||
|   text: string | ||||
|   status: LabelStatus | ||||
| } | ||||
| 
 | ||||
| export class Label { | ||||
|   type: LabelType; | ||||
|   displayText: string | null; | ||||
|   text: string; | ||||
|   status: LabelStatus; | ||||
|   constructor({ type, displayText, text, status }: LabelData) { | ||||
|     this.type = type ?? LabelType.Unspecified; | ||||
|     this.displayText = displayText; | ||||
|     this.text = text; | ||||
|     this.status = status; | ||||
|   } | ||||
| 
 | ||||
|   display(): string { | ||||
|     return this.displayText ?? this.text; | ||||
|   } | ||||
| 
 | ||||
|   shortDisplay(): string { | ||||
|     return this.display(); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export class Name extends Label { | ||||
|   constructor({ name, status }: API.Name) { | ||||
|     super({ | ||||
|       type: LabelType.Name, | ||||
|       displayText: null, | ||||
|       text: name, | ||||
|       status, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export class Pronouns extends Label { | ||||
|   constructor({ display_text, pronouns, status }: API.Pronoun) { | ||||
|     super({ | ||||
|       type: LabelType.Pronouns, | ||||
|       displayText: display_text ?? null, | ||||
|       text: pronouns, | ||||
|       status, | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   get pronouns(): string[] { return this.text.split('/'); } | ||||
|   set pronouns(to: string[]) { this.text = to.join('/'); } | ||||
| 
 | ||||
|   shortDisplay(): string { | ||||
|     return this.displayText ?? this.pronouns.splice(0, 2).join('/'); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export class Field { | ||||
|   name: string; | ||||
|   labels: Label[]; | ||||
|   constructor({ name, favourite, okay, jokingly, friends_only, avoid }: API.Field) { | ||||
|     this.name = name; | ||||
|     function transpose(arr: API.Arr<string>, status: LabelStatus): Label[] { | ||||
|       return (arr ?? []).map(text => new Label({ | ||||
|         displayText: null, | ||||
|         text, | ||||
|         status, | ||||
|       })); | ||||
|     } | ||||
|     this.labels = [ | ||||
|       ...transpose(favourite,    LabelStatus.Favourite), | ||||
|       ...transpose(okay,         LabelStatus.Okay), | ||||
|       ...transpose(jokingly,     LabelStatus.Jokingly), | ||||
|       ...transpose(friends_only, LabelStatus.FriendsOnly), | ||||
|       ...transpose(avoid,        LabelStatus.Avoid), | ||||
|     ]; | ||||
|   } | ||||
| } | ||||
|  | @ -1,32 +0,0 @@ | |||
| import type { APIError } from "./types"; | ||||
| 
 | ||||
| const apiBase = process.env.API_BASE ?? "/api"; | ||||
| 
 | ||||
| export default async function fetchAPI<T>( | ||||
|   path: string, | ||||
|   method = "GET", | ||||
|   body: any = null | ||||
| ) { | ||||
|   let headers = {}; | ||||
|   const token = | ||||
|     typeof localStorage !== "undefined" && | ||||
|     localStorage.getItem("pronouns-token"); | ||||
|   if (token) { | ||||
|     headers = { | ||||
|       Authorization: token, | ||||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   const resp = await fetch(`${apiBase}/v1${path}`, { | ||||
|     method, | ||||
|     headers: { | ||||
|       ...headers, | ||||
|       "Content-Type": "application/json", | ||||
|     }, | ||||
|     body: body ? JSON.stringify(body) : null, | ||||
|   }); | ||||
| 
 | ||||
|   const data = await resp.json(); | ||||
|   if (resp.status < 200 || resp.status >= 300) throw data as APIError; | ||||
|   return data as T; | ||||
| } | ||||
|  | @ -1,5 +1,5 @@ | |||
| import { atom } from "recoil"; | ||||
| import { MeUser } from "./types"; | ||||
| import { MeUser } from "./api-fetch"; | ||||
| 
 | ||||
| export const userState = atom<MeUser | null>({ | ||||
|   key: "userState", | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue