diff --git a/docs/apl.md b/docs/apl.md index cf8d7e3..c525675 100644 --- a/docs/apl.md +++ b/docs/apl.md @@ -184,3 +184,27 @@ const apl = new UpstashAPL({ ``` Or using environment variables: `UPSTASH_TOKEN`, `UPSTASH_URL`. + +### RestAPL + +RestAPL is a simple CRUD client that performs basic operations on resource provided in constructor + +Example usage: + +```typescript +import { RestAPL } from "@saleor/app-sdk/APL"; + +const apl = new RestAPL({ + resourceUrl: "https://crudcrud.com/api/2c35cdbf2a2c48669b2feda4ab260e1c", // Required, should point to CRUD resource + headers: { + Authorization: "Bearer XYZ", // Optional. Headers will be merged into request, allowing to pass auth. + }, +}); +``` + +Target resource must accept "standard" CRUD operations: + +- `GET /` - get all +- `GET /:domain` - get AuthData for given domain +- `POST /` - set AuthData (`{domain: string, token: string}`) from BODY +- `DELETE /:domain` - deletes AuthData for given domain diff --git a/src/APL/index.ts b/src/APL/index.ts index 59100fc..07480ea 100644 --- a/src/APL/index.ts +++ b/src/APL/index.ts @@ -1,4 +1,5 @@ export * from "./apl"; export * from "./file-apl"; +export * from "./rest-apl"; export * from "./upstash-apl"; export * from "./vercel-apl"; diff --git a/src/APL/rest-apl.ts b/src/APL/rest-apl.ts new file mode 100644 index 0000000..99a280b --- /dev/null +++ b/src/APL/rest-apl.ts @@ -0,0 +1,145 @@ +import { hasProp } from "../has-prop"; +import { APL, AplConfiguredResult, AplReadyResult, AuthData } from "./apl"; +import { createAPLDebug } from "./apl-debug"; + +const debug = createAPLDebug("RestAPL"); + +export type RestAPLConfig = { + resourceUrl: string; + headers?: Record; +}; + +const validateResponseStatus = (response: Response) => { + if (response.status < 200 || response.status >= 400) { + debug("Response failed with status %s", response.status); + + throw new Error(`Fetch returned with non 200 status code ${response.status}`); + } +}; + +/** + * TODO Add test + */ +export class RestAPL implements APL { + private readonly resourceUrl: string; + + private headers?: Record; + + constructor(config: RestAPLConfig) { + this.resourceUrl = config.resourceUrl; + this.headers = config.headers; + } + + private getUrlForDomain(domain: string) { + return `${this.resourceUrl}/${domain}`; + } + + async get(domain: string): Promise { + debug("Will fetch data from RestAPL for domain %s", domain); + + const response = await fetch(this.getUrlForDomain(domain), { + method: "GET", + headers: { "Content-Type": "application/json", ...this.headers }, + }).catch((error) => { + debug("Failed to reach API call: %s", error?.message ?? "Unknown error"); + throw new Error(`Attempt in fetch the data resulted with error: ${error}`); + }); + + validateResponseStatus(response); + + const parsedResponse = (await response.json().catch((e) => { + debug("Failed to parse response: %s", e?.message ?? "Unknown error"); + })) as unknown; + + if (hasProp(parsedResponse, "domain") && hasProp(parsedResponse, "token")) { + return { domain: parsedResponse.domain as string, token: parsedResponse.token as string }; + } + + debug("Response had no domain and token."); + + return undefined; + } + + async set(authData: AuthData) { + debug("Saving data to RestAPL for domain: %s", authData.domain); + + const response = await fetch(this.resourceUrl, { + method: "POST", + headers: { "Content-Type": "application/json", ...this.headers }, + body: JSON.stringify(authData), + }).catch((e) => { + debug("Failed to reach API call: %s", e?.message ?? "Unknown error"); + + throw new Error(`Error during saving the data: ${e}`); + }); + + validateResponseStatus(response); + + debug("Set command finished successfully for domain: %", authData.domain); + + return undefined; + } + + async delete(domain: string) { + debug("Deleting data from Rest for domain: %s", domain); + + try { + const response = await fetch(this.getUrlForDomain(domain), { + method: "DELETE", + headers: { "Content-Type": "application/json", ...this.headers }, + body: JSON.stringify({ domain }), + }); + + debug(`Delete responded with ${response.status} code`); + } catch (error) { + debug("Error during deleting the data: %s", error); + + throw new Error(`Error during saving the data: ${error}`); + } + } + + async getAll() { + debug("Get all data from Rest"); + + try { + const response = await fetch(this.resourceUrl, { + method: "GET", + headers: { "Content-Type": "application/json", ...this.headers }, + }); + + debug(`Get all responded with ${response.status} code`); + + return ((await response.json()) as AuthData[]) || []; + } catch (error) { + debug("Error during getting all the data:", error); + } + + return []; + } + + async isReady(): Promise { + const configured = await this.isConfigured(); + + return configured + ? { + ready: true, + } + : { + ready: false, + error: new Error("App is not configured"), + }; + } + + async isConfigured(): Promise { + if (!this.resourceUrl) { + return { + configured: false, + error: new Error("RestAPL required resourceUrl param"), + }; + } + + return { + configured: true, + }; + } +} diff --git a/src/has-prop.ts b/src/has-prop.ts new file mode 100644 index 0000000..b409356 --- /dev/null +++ b/src/has-prop.ts @@ -0,0 +1,11 @@ +/** + * Safely narrow unknown object and infer property existence + * @param obj + * @param key + */ +export function hasProp( + obj: unknown, + key: K | null | undefined +): obj is Record { + return key != null && obj != null && typeof obj === "object" && key in obj; +}