chore: Platform SDK (#14154)

This commit is contained in:
Erik
2024-03-20 12:33:49 +00:00
committed by GitHub
parent 9bb13a0b47
commit c50e04e18b
28 changed files with 3175 additions and 62 deletions
+8
View File
@@ -0,0 +1,8 @@
module.exports = {
git: {
commitMessage: "chore: SDK Release v${version}",
requireUpstream: true,
requireCommits: true,
requireBranch: "feat/platform-sdk",
},
};
+7
View File
@@ -0,0 +1,7 @@
<p align="center">
<a href="https://github.com/calcom/cal.com">
<img src="https://user-images.githubusercontent.com/8019099/210054112-5955e812-a76e-4160-9ddd-58f2c72f1cce.png" alt="Logo">
</a>
<br/>
<strong>Cal.com Atoms SDK</strong>
</p>
+54
View File
@@ -0,0 +1,54 @@
import { CalSdk } from "../src/cal";
(async () => {
const sdk = new CalSdk(
"cltd1rit60001p51en7z0sq75",
{
accessToken:
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoiYWNjZXNzX3Rva2VuIiwiY2xpZW50SWQiOiJjbHRkMXJpdDYwMDAxcDUxZW43ejBzcTc1Iiwib3duZXJJZCI6OSwiaWF0IjoxNzA5NTYzNTMyfQ.tpUSk0gREAZPJA2aXnHPpVc-9Bbcj2w90kVH2Qii6RE",
clientSecret:
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoidGVzdCBjbGllbnQiLCJwZXJtaXNzaW9ucyI6MjU1LCJyZWRpcmVjdFVyaXMiOlsiaHR0cDovL2xvY2FsaG9zdDozMDAwL2F1dGgvcGxhdGZvcm0vYXV0aG9yaXplIl0sImlhdCI6MTcwOTU2MzE1N30.j8cxam5pfPG45BAMCuXt7bm3GM7JO_UnWL9wPVGcr5U",
refreshToken:
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoicmVmcmVzaF90b2tlbiIsImNsaWVudElkIjoiY2x0ZDFyaXQ2MDAwMXA1MWVuN3owc3E3NSIsIm93bmVySWQiOjksImlhdCI6MTcwOTU2MzUzMn0.SHMikF_9uJvqJFAiWAnI0BsRQSJCTbaAXPi1B99sWNk",
},
{
baseUrl: "http://localhost:5555/api",
handleRefresh: true,
httpRetries: {
maxAmount: 5,
},
}
);
try {
const oauth = await sdk.oauth.exchange({
authorizationCode: "cltd1wlqn0002p51ektm2byp6",
});
console.log("finalized oauth", oauth.accessToken);
// force updating
sdk.secrets().updateAccessToken(oauth.accessToken, oauth.refreshToken);
const schedule = await sdk.schedules.createSchedule({
availabilities: [
{
days: [1, 2, 3, 4, 5, 6],
startTime: "10:00:00",
endTime: "14:00:00",
},
],
isDefault: true,
name: "Default Schedule Test",
timeZone: "America/Argentina/Buenos_Aires",
});
console.log(schedule);
const deleted = await sdk.schedules.deleteSchedule(schedule.id);
console.log(deleted);
} catch (err) {
console.log("error", err);
}
})();
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@calcom/sdk",
"version": "0.0.1",
"description": "Cal.com platform SDK.",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "rollup -c --bundleConfigAsCjs",
"release": "release-it --only-version"
},
"packageManager": "yarn@3.4.1",
"devDependencies": {
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.2.3",
"@rollup/plugin-typescript": "^11.1.6",
"release-it": "^17.1.1",
"rollup": "^4.12.0",
"rollup-plugin-bundle-size": "^1.0.3",
"ts-essentials": "^9.4.1",
"typescript": "^5.3.3"
},
"dependencies": {
"axios": "^1.6.7",
"axios-retry": "^4.0.0",
"lodash": "^4.17.21",
"zod": "^3.22.4"
}
}
+30
View File
@@ -0,0 +1,30 @@
import commonjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import typescript from "@rollup/plugin-typescript";
import bundleSize from "rollup-plugin-bundle-size";
import pkg from "./package.json";
/** @type {import('rollup').RollupOptions} */
// eslint-disable-next-line import/no-anonymous-default-export
export default {
input: "src/index.ts",
output: [
{
file: pkg.main,
format: "cjs",
},
{
file: pkg.module,
format: "es",
},
],
plugins: [
json(),
commonjs(),
nodeResolve(),
typescript({ tsconfig: "./tsconfig.build.json" }),
bundleSize(),
],
};
+75
View File
@@ -0,0 +1,75 @@
import axios from "axios";
import axiosRetry from "axios-retry";
import { Bookings } from "./endpoints/bookings";
import { Events } from "./endpoints/events";
import { EventTypes } from "./endpoints/events/event-types";
import { OAuthFlow } from "./endpoints/oauth-flow";
import { Schedules } from "./endpoints/schedules";
import { Slots } from "./endpoints/slots";
import { SdkInitializationError } from "./lib/errors/sdk-initialization-error";
import { HttpCaller } from "./lib/http-caller";
import { SdkSecrets } from "./lib/sdk-secrets";
import type { CalSdkConstructorOptions, SdkAuthOptions } from "./types";
export class CalSdk {
public httpCaller: HttpCaller;
slots: Slots;
bookings: Bookings;
events: Events;
oauth: OAuthFlow;
eventTypes: EventTypes;
schedules: Schedules;
private _secrets: SdkSecrets;
constructor(
public readonly clientId: string,
protected readonly authOptions: SdkAuthOptions,
protected readonly options: CalSdkConstructorOptions = {
baseUrl: "https://api.cal.com/", // don't set api version here as endpoints may have version-neutral or specific values.
}
) {
if (!authOptions.accessToken && !authOptions.clientSecret) {
throw new SdkInitializationError("Either 'accessToken' or 'clientSecret' are required in authOptions");
}
this.httpCaller = new HttpCaller(this.clientId, this._createAxiosClientBase());
this._secrets = new SdkSecrets(
authOptions.clientSecret ?? "",
authOptions.accessToken ?? "",
authOptions.refreshToken ?? "",
this.httpCaller
);
// avoid cyclic referencing.
this.httpCaller.secrets = this._secrets;
this.slots = new Slots(this);
this.bookings = new Bookings(this);
this.events = new Events(this);
this.oauth = new OAuthFlow(this);
this.eventTypes = new EventTypes(this);
this.schedules = new Schedules(this);
}
private _createAxiosClientBase() {
const axiosClient = axios.create({
baseURL: this.options.baseUrl,
});
// implement retry logic with an exponential back-off delay
axiosRetry(axiosClient, {
retries: this.options.httpRetries?.maxAmount ?? 3,
retryDelay: axiosRetry.exponentialDelay,
retryCondition: axiosRetry.isNetworkOrIdempotentRequestError,
});
return axiosClient;
}
public secrets() {
return this._secrets;
}
}
@@ -0,0 +1,8 @@
import type { CalSdk } from "../../cal";
import { EndpointHandler } from "../endpoint-handler";
export class Bookings extends EndpointHandler {
constructor(private readonly sdk: CalSdk) {
super("bookings", sdk);
}
}
@@ -0,0 +1,34 @@
import type { AxiosRequestConfig } from "axios";
// eslint-disable-next-line no-restricted-imports
import merge from "lodash/merge";
import { assert } from "ts-essentials";
import type { CalSdk } from "../cal";
export abstract class EndpointHandler {
constructor(private readonly key: string, private readonly calSdk: CalSdk) {}
withForAtomParam(forAtom: boolean, config?: AxiosRequestConfig<unknown>) {
if (!forAtom) return config;
return merge(config, {
params: {
for: "atom",
},
});
}
protected assertAccessToken(methodName: string) {
assert(
this.calSdk.secrets().isAccessTokenSet(),
`Access token must be set to call the ${this.key}/${methodName} function.`
);
}
protected assertClientSecret(methodName: string) {
assert(
!!this.calSdk.secrets().getClientSecret(),
`Client secret must be set to use the ${this.key}/${methodName} function.`
);
}
}
@@ -0,0 +1,42 @@
import type { CalSdk } from "../../../cal";
import { Endpoints } from "../../../lib/endpoints";
import type { BasicPlatformResponse } from "../../../types";
import { EndpointHandler } from "../../endpoint-handler";
import type { CreateEventTypeArgs, EventType, GetEventTypeByIdArgs } from "./types";
type BodyWithEventTypeResponse = {
eventType: EventType;
};
export class EventTypes extends EndpointHandler {
constructor(private readonly sdk: CalSdk) {
super("event-types", sdk);
}
async createEventType(args: CreateEventTypeArgs): Promise<EventType> {
this.assertAccessToken("createEventType");
const { data } = await this.sdk.httpCaller.post<BasicPlatformResponse<BodyWithEventTypeResponse>>(
Endpoints.CREATE_EVENT_TYPE,
{
body: args,
}
);
return data.eventType;
}
async getEventType(args: GetEventTypeByIdArgs): Promise<EventType> {
this.assertAccessToken("getEventType");
const { data } = await this.sdk.httpCaller.get<BasicPlatformResponse<BodyWithEventTypeResponse>>(
Endpoints.GET_EVENT_TYPE_BY_ID,
{
urlParams: [args.id],
config: this.withForAtomParam(args.forAtom ?? false),
}
);
return data.eventType;
}
}
@@ -0,0 +1,64 @@
import { z } from "zod";
const CreateEventTypesSchema = z.object({
length: z.number().min(1),
slug: z.string(),
title: z.string(),
});
const GetEventTypeByIdSchema = z.object({
id: z.string(),
forAtom: z.boolean().optional(),
});
export type CreateEventTypeArgs = z.infer<typeof CreateEventTypesSchema>;
export type GetEventTypeByIdArgs = z.infer<typeof GetEventTypeByIdSchema>;
export type EventType = {
id: string;
title: string;
slug: string;
description: string | null;
position: number;
locations: string[] | null;
length: number;
offsetStart: number;
hidden: boolean;
userId: number;
profileId: number | null;
teamId: number | null;
eventName: string | null;
parentId: number | null;
bookingFields: unknown | null;
timeZone: string | null;
periodType: "UNLIMITED" | "ROLLING" | "RANGE";
periodStartDate: string | null;
periodEndDate: string | null;
periodDays: number | null;
periodCountCalendarDays: boolean | null;
lockTimeZoneToggleOnBookingPage: boolean;
requiresConfirmation: boolean;
requiresBookerEmailVerification: boolean;
recurringEvent?: boolean;
disableGuests: boolean;
hideCalendarNotes: boolean;
minimumBookingNotice: number;
beforeEventBuffer: number;
afterEventBuffer: number;
seatsPerTimeSlot: number | null;
onlyShowFirstAvailableSlot: boolean;
seatsShowAttendees: boolean;
seatsShowAvailabilityCount: boolean;
schedulingType: unknown | null;
scheduleId: number | null;
price: number;
currency: "usd";
slotInterval: number | null;
metadata: Record<string, unknown> | null;
successRedirectUrl: string | null;
bookingLimits: number | null;
durationLimits: number | null;
isInstantEvent: boolean;
assignAllTeamMembers: boolean;
useEventTypeDestinationCalendarEmail: boolean;
};
@@ -0,0 +1,21 @@
import type { CalSdk } from "../../cal";
import { Endpoints } from "../../lib/endpoints";
import type { BasicPlatformResponse } from "../../types";
import { EndpointHandler } from "../endpoint-handler";
import type { Event, GetPublicEventArgs } from "./types";
export class Events extends EndpointHandler {
constructor(private readonly sdk: CalSdk) {
super("events", sdk);
}
async getPublicEvent(input: GetPublicEventArgs): Promise<Event> {
const { data } = await this.sdk.httpCaller.get<BasicPlatformResponse<Event>>(Endpoints.GET_PUBLIC_EVENT, {
config: {
params: input,
},
});
return data;
}
}
@@ -0,0 +1,71 @@
import { z } from "zod";
const GetPublicEventSchema = z.object({
username: z.string().transform((s) => s.toLowerCase()),
eventSlug: z.string(),
isTeamEvent: z.boolean().optional(),
org: z.string().optional(),
});
export type GetPublicEventArgs = z.infer<typeof GetPublicEventSchema>;
type User = {
// todo expand
};
type TeamParent = {
slug: string;
name: string;
};
type Team = {
parentId: string;
metadata: Record<string, unknown>;
brandColor: string;
darkBrandColor: string;
slug: string;
name: string;
logo: string;
theme: string;
parent: TeamParent;
};
type WorkflowStep = {
// todo expand
};
type Workflow = {
steps: WorkflowStep[];
};
export type Event = {
id: string;
title: string;
description: string;
eventName: string;
slug: string;
isInstantEvent: boolean;
schedulingType: string;
length: number;
locations: string[]; // Define more specifically if possible
customInputs: unknown; // Define more specifically if possible
disableGuests: boolean;
lockTimeZoneToggleOnBookingPage: boolean;
requiresConfirmation: boolean;
requiresBookerEmailVerification: boolean;
recurringEvent: boolean;
price: number;
currency: string;
seatsPerTimeSlot: number;
seatsShowAvailabilityCount: boolean;
bookingFields: unknown; // Define more specifically if possible
team: Team;
successRedirectUrl: string;
workflows: Workflow[];
hosts: {
user: User;
}[];
owner: User;
hidden: boolean;
assignAllTeamMembers: boolean;
};
@@ -0,0 +1,32 @@
import type { CalSdk } from "../../cal";
import { Endpoints } from "../../lib/endpoints";
import type { BasicPlatformResponse } from "../../types";
import { EndpointHandler } from "../endpoint-handler";
import type { ExchangeCodeParams, ExchangeCodeResponse } from "./types";
export class OAuthFlow extends EndpointHandler {
constructor(private readonly sdk: CalSdk) {
super("oauth", sdk);
}
async exchange(params: ExchangeCodeParams): Promise<ExchangeCodeResponse> {
this.assertClientSecret("exchange");
const { data } = await this.sdk.httpCaller.post<BasicPlatformResponse<ExchangeCodeResponse>>(
Endpoints.EXCHANGE_OAUTH_AUTH_TOKEN,
{
urlParams: [this.sdk.clientId],
body: {
clientSecret: this.sdk.secrets().getClientSecret(),
},
config: {
headers: {
Authorization: `Bearer ${params.authorizationCode}`,
},
},
}
);
return data;
}
}
@@ -0,0 +1,12 @@
import { z } from "zod";
const ExchangeCodeSchema = z.object({
authorizationCode: z.string(),
});
export type ExchangeCodeParams = z.infer<typeof ExchangeCodeSchema>;
export type ExchangeCodeResponse = {
accessToken: string;
refreshToken: string;
};
@@ -0,0 +1,106 @@
import type { CalSdk } from "../../cal";
import { Endpoints } from "../../lib/endpoints";
import type { BasicPlatformResponse } from "../../types";
import { EndpointHandler } from "../endpoint-handler";
import type {
CreateScheduleArgs,
FormattedSchedule,
GetScheduleByIdArgs,
Schedule,
SupportedTimezone,
UpdateScheduleArgs,
} from "./types";
export class Schedules extends EndpointHandler {
constructor(private readonly sdk: CalSdk) {
super("schedules", sdk);
}
async createSchedule(args: CreateScheduleArgs): Promise<Schedule> {
this.assertAccessToken("createSchedule");
const { data } = await this.sdk.httpCaller.post<BasicPlatformResponse<Schedule>>(
Endpoints.CREATE_SCHEDULE,
{
body: args,
config: this.withForAtomParam(args.forAtom ?? false),
}
);
return data;
}
async getDefaultSchedule(forAtom?: boolean): Promise<Schedule | FormattedSchedule> {
this.assertAccessToken("getDefaultSchedule");
const { data } = await this.sdk.httpCaller.get<BasicPlatformResponse<Schedule | FormattedSchedule>>(
Endpoints.GET_DEFAULT_SCHEDULE,
{
config: this.withForAtomParam(forAtom ?? false),
}
);
return data;
}
async getSchedules(forAtom?: boolean): Promise<Schedule[] | FormattedSchedule[]> {
this.assertAccessToken("getSchedules");
const { data } = await this.sdk.httpCaller.get<BasicPlatformResponse<Schedule[] | FormattedSchedule[]>>(
Endpoints.GET_ALL_SCHEDULES,
{
config: this.withForAtomParam(forAtom ?? false),
}
);
return data;
}
async getScheduleById(args: GetScheduleByIdArgs): Promise<Schedule | FormattedSchedule> {
this.assertAccessToken("getScheduleById");
const { data } = await this.sdk.httpCaller.get<BasicPlatformResponse<Schedule | FormattedSchedule>>(
Endpoints.GET_SCHEDULE_BY_ID,
{
urlParams: [args.id.toString()],
config: this.withForAtomParam(args.forAtom ?? false),
}
);
return data;
}
async getSupportedTimezones(): Promise<SupportedTimezone[]> {
const { data } = await this.sdk.httpCaller.get<BasicPlatformResponse<SupportedTimezone[]>>(
Endpoints.GET_SUPPORTED_TIMEZONES
);
return data;
}
async updateSchedule(scheduleId: number, args: UpdateScheduleArgs) {
this.assertAccessToken("updateSchedule");
const { data } = await this.sdk.httpCaller.patch<BasicPlatformResponse<Schedule>>(
Endpoints.UPDATE_SCHEDULE_BY_ID,
{
urlParams: [scheduleId.toString()],
body: args,
}
);
return data;
}
async deleteSchedule(scheduleId: number): Promise<boolean> {
this.assertAccessToken("deleteSchedule");
const { status } = await this.sdk.httpCaller.delete<BasicPlatformResponse>(
Endpoints.DELETE_SCHEDULE_BY_ID,
{
urlParams: [scheduleId.toString()],
}
);
return status === "success";
}
}
@@ -0,0 +1,63 @@
import type { ElementOf } from "ts-essentials";
import { z } from "zod";
const CreateScheduleSchema = z.object({
name: z.string(),
timeZone: z.string(),
isDefault: z.boolean().default(true),
availabilities: z
.object({
days: z.number().array(),
startTime: z.string(),
endTime: z.string(),
})
.array(),
forAtom: z.boolean().optional(),
});
const GetScheduleByIdSchema = z.object({
id: z.number(),
forAtom: z.boolean().optional(),
});
const UpdateScheduleSchema = z.object({
timeZone: z.string().optional(),
name: z.string().optional(),
isDefault: z.boolean().optional(),
schedule: z
.object({
start: z.date(),
end: z.date(),
})
.array()
.optional(),
dateOverrides: z.object({
ranges: z.object({}),
}),
});
export type CreateScheduleArgs = z.infer<typeof CreateScheduleSchema>;
export type GetScheduleByIdArgs = z.infer<typeof GetScheduleByIdSchema>;
export type UpdateScheduleArgs = z.infer<typeof UpdateScheduleSchema>;
export type Schedule = CreateScheduleArgs & {
id: number;
userId: number;
};
export type FormattedSchedule = {
id: number;
name: string;
isManaged: boolean;
schedule: ElementOf<Schedule["availabilities"]>;
timeZone: string;
isDefault: boolean;
isLastSchedule: boolean;
readOnly: boolean;
};
export type SupportedTimezone = {
city: string;
timezone: string;
pop: number;
};
@@ -0,0 +1,46 @@
import type { CalSdk } from "../../cal";
import { Endpoints } from "../../lib/endpoints";
import { type BasicPlatformResponse, type ResponseStatus } from "../../types";
import { EndpointHandler } from "../endpoint-handler";
import type {
AvailableSlots,
GetAvaialbleSlotsArgs,
RemoveSelectedSlotArgs,
ReserveSlotArgs,
SlotUID,
} from "./types";
export class Slots extends EndpointHandler {
constructor(private readonly sdk: CalSdk) {
super("slots", sdk);
}
async reserveSlot(args: ReserveSlotArgs): Promise<SlotUID> {
const { data } = await this.sdk.httpCaller.post<BasicPlatformResponse<SlotUID>>(Endpoints.RESERVE_SLOT, {
body: args,
});
return data;
}
async removeSelectedSlot(args: RemoveSelectedSlotArgs): Promise<ResponseStatus> {
const { status } = await this.sdk.httpCaller.delete<BasicPlatformResponse>(
Endpoints.DELETE_SELECTED_SLOT,
{
config: { params: args },
}
);
return status === "success" ? "success" : "error";
}
async getAvailableSlots(args: GetAvaialbleSlotsArgs): Promise<AvailableSlots> {
const { data } = await this.sdk.httpCaller.get<BasicPlatformResponse<AvailableSlots>>(
Endpoints.AVAILABLE_SLOTS,
{
config: {
params: args,
},
}
);
return data;
}
}
@@ -0,0 +1,38 @@
import { z } from "zod";
export type SlotUID = string;
type Slot = Record<
SlotUID,
{
time: string;
attendees?: number;
bookingUid?: string;
}
>;
export type AvailableSlots = Slot[];
const ReserveSlotSchema = z.object({
eventTypeId: z.number(),
slotUtcStartDate: z.date(),
slotUtcEndDate: z.date(),
bookingUid: z.string().optional(),
});
const RemoveSelectedSlotSchema = z.object({
uid: z.string().optional(),
});
const GetAvailableSlotsSchema = z.object({
startTime: z.date(),
endTime: z.date(),
eventTypeId: z.number().optional(),
usernameList: z.string().array().optional(),
debug: z.boolean().optional(),
duration: z.number().optional(),
});
export type ReserveSlotArgs = z.infer<typeof ReserveSlotSchema>;
export type RemoveSelectedSlotArgs = z.infer<typeof RemoveSelectedSlotSchema>;
export type GetAvaialbleSlotsArgs = z.infer<typeof GetAvailableSlotsSchema>;
+2
View File
@@ -0,0 +1,2 @@
export { CalSdk as Cal } from "./cal";
export * from "./types";
+176
View File
@@ -0,0 +1,176 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ApiVersion } from "../types";
type BaseEndpointDeclaration = {
auth: "public" | "access_token" | "secret";
apiVersion: ApiVersion;
};
// Define a type for static URIs
type StaticUriEndpoint = BaseEndpointDeclaration & {
uri: string;
constructUri?: never; // Ensure constructUri is not used here
};
// Introduce a generic type for dynamic URIs, allowing for explicit parameter type declaration
type DynamicUriEndpoint<ParamsType extends Record<string, string> | string[]> = BaseEndpointDeclaration & {
uri?: never; // Ensure uri is not used here
constructUri: (params: ParamsType) => string;
};
// Create a discriminated union of the two types, incorporating generics
type EndpointDeclaration<ParamsType extends Record<string, string> | string[] = string[]> =
| StaticUriEndpoint
| DynamicUriEndpoint<ParamsType>;
export enum Endpoints {
RESERVE_SLOT = "RESERVE_SLOT",
DELETE_SELECTED_SLOT = "DELETE_SELECTED_SLOT",
AVAILABLE_SLOTS = "AVAILABLE_SLOTS",
GET_PUBLIC_EVENT = "GET_PUBLIC_EVENT",
EXCHANGE_OAUTH_AUTH_TOKEN = "EXCHANGE_OAUTH_AUTH_TOKEN",
REFRESH_OAUTH_TOKEN = "REFRESH_OAUTH_TOKEN",
CREATE_EVENT_TYPE = "CREATE_EVENT_TYPE",
GET_EVENT_TYPE_BY_ID = "GET_EVENT_TYPE_BY_ID",
CREATE_SCHEDULE = "CREATE_SCHEDULE",
GET_DEFAULT_SCHEDULE = "GET_DEFAULT_SCHEDULE",
GET_ALL_SCHEDULES = "GET_ALL_SCHEDULES",
GET_SCHEDULE_BY_ID = "GET_SCHEDULE_BY_ID",
GET_SUPPORTED_TIMEZONES = "GET_SUPPORTED_TIMEZONES",
UPDATE_SCHEDULE_BY_ID = "UPDATE_SCHEDULE_BY_ID",
DELETE_SCHEDULE_BY_ID = "DELETE_SCHEDULE_BY_ID",
}
const publicEndpoint = (uri: string, version = ApiVersion.NEUTRAL): EndpointDeclaration => ({
uri,
auth: "public",
apiVersion: version,
});
// Adjust the constructor to use generics for flexible parameter typing
const publicEndpointConstructor = <ParamsType extends Record<string, string> | string[]>(
constructUri: (params: ParamsType) => string,
version = ApiVersion.NEUTRAL
): EndpointDeclaration<ParamsType> => ({
constructUri,
auth: "public",
apiVersion: version,
});
const ENDPOINTS: Record<Endpoints, EndpointDeclaration> = {
RESERVE_SLOT: publicEndpoint("slots/reserve", ApiVersion.V2),
AVAILABLE_SLOTS: publicEndpoint("slots/available", ApiVersion.V2),
DELETE_SELECTED_SLOT: publicEndpoint("slots/delete", ApiVersion.V2),
GET_PUBLIC_EVENT: publicEndpoint("events/"),
EXCHANGE_OAUTH_AUTH_TOKEN: publicEndpointConstructor<string[]>(
([clientId]) => `oauth/${clientId}/exchange`,
ApiVersion.V2
),
REFRESH_OAUTH_TOKEN: publicEndpointConstructor<string[]>(
([clientId]) => `oauth/${clientId}/refresh`,
ApiVersion.V2
),
CREATE_EVENT_TYPE: {
apiVersion: ApiVersion.V2,
auth: "access_token",
uri: `event-types`,
},
GET_EVENT_TYPE_BY_ID: {
apiVersion: ApiVersion.V2,
auth: "access_token",
constructUri([eventTypeId]) {
return `event-types/${eventTypeId}`;
},
},
CREATE_SCHEDULE: {
apiVersion: ApiVersion.V2,
auth: "access_token",
uri: "schedules",
},
GET_DEFAULT_SCHEDULE: {
apiVersion: ApiVersion.V2,
auth: "access_token",
uri: "schedules/default",
},
GET_ALL_SCHEDULES: {
apiVersion: ApiVersion.V2,
auth: "access_token",
uri: "schedules",
},
GET_SCHEDULE_BY_ID: {
apiVersion: ApiVersion.V2,
auth: "access_token",
constructUri([scheduleId]) {
return `schedules/${scheduleId}`;
},
},
GET_SUPPORTED_TIMEZONES: publicEndpoint("schedules/time-zones", ApiVersion.V2),
UPDATE_SCHEDULE_BY_ID: {
apiVersion: ApiVersion.V2,
auth: "access_token",
constructUri([scheduleId]) {
return `schedules/${scheduleId}`;
},
},
DELETE_SCHEDULE_BY_ID: {
apiVersion: ApiVersion.V2,
auth: "access_token",
constructUri([scheduleId]) {
return `schedules/${scheduleId}`;
},
},
} as const;
const isParamsRecord = (params: unknown): params is Record<string, string> => {
return params !== null && typeof params === "object" && !Array.isArray(params);
};
export const getEndpointDefinition = (endpoint: Endpoints): BaseEndpointDeclaration => {
return ENDPOINTS[endpoint];
};
export const getEndpointData = (
endpoint: Endpoints,
params?: Record<string, string> | string[]
): {
uri: string;
version: ApiVersion;
auth: EndpointDeclaration["auth"];
} => {
const endpointData = ENDPOINTS[endpoint];
// Determine if the endpoint expects a dynamic URI construction
if (typeof endpointData.constructUri === "function") {
if (!params) {
throw new Error(`Parameters are required for dynamic ${endpoint} endpoint.`);
}
// Here, we need to determine the correct type of params at runtime
let constructedUri: string;
if (isParamsRecord(params)) {
// Params is a Record<string, string>, handle accordingly
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
constructedUri = endpointData.constructUri(params as Record<string, string>);
} else if (Array.isArray(params)) {
// Params is string[], handle accordingly
constructedUri = endpointData.constructUri(params as string[]);
} else {
throw new Error("Invalid parameter type for dynamic endpoint.");
}
return {
version: endpointData.apiVersion,
uri: constructedUri,
auth: endpointData.auth,
};
} else if (endpointData.uri) {
return {
version: endpointData.apiVersion,
uri: endpointData.uri,
auth: endpointData.auth,
};
} else {
throw new Error(`Endpoint configuration error for ${endpoint}`);
}
};
@@ -0,0 +1,6 @@
export class CalApiError extends Error {
constructor(public message: string, public statusCode: number) {
super(message);
this.name = "CalApiError";
}
}
@@ -0,0 +1,6 @@
export class SdkInitializationError extends Error {
constructor(message: string) {
super(`Failed to initialize SDK: ${message}`);
this.name = "SdkInitializationError";
}
}
@@ -0,0 +1,168 @@
import type { AxiosError, AxiosRequestConfig } from "axios";
import { type AxiosInstance } from "axios";
// eslint-disable-next-line no-restricted-imports
import merge from "lodash/merge";
import type { Endpoints } from "./endpoints";
import { getEndpointData, getEndpointDefinition } from "./endpoints";
import { CalApiError } from "./errors/cal-api-error";
import type { SdkSecrets } from "./sdk-secrets";
interface HttpCallerOptions {
shouldHandleRefresh?: boolean;
}
export const X_CAL_SECRET_KEY = "x-cal-secret-key";
type CallOptions = {
urlParams?: Record<string, string> | string[];
config?: AxiosRequestConfig<unknown>;
};
type CallOptionsWithBody<T = unknown> = CallOptions & {
body: T;
};
export class HttpCaller {
private awaitingRefresh = false;
secrets: SdkSecrets | null = null;
private requestQueue: Array<() => void> = [];
constructor(
private readonly clientId: string,
private readonly axiosClient: AxiosInstance,
private readonly options?: HttpCallerOptions
) {
this.setupInterceptors();
}
private async retryQueuedRequests() {
while (this.requestQueue.length > 0) {
const retryRequest = this.requestQueue.shift();
if (retryRequest) {
retryRequest();
}
}
}
private setupInterceptors() {
this.axiosClient.interceptors.request.use(async (config) => {
if (this.awaitingRefresh) {
// Return a new promise that resolves when the refresh is complete
// and then retries the request.
return new Promise((resolve) => {
this.requestQueue.push(() => resolve(config));
});
}
return config;
});
this.axiosClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
if (error.response?.status === 498) {
if (!this.awaitingRefresh) {
this.awaitingRefresh = true;
try {
await this.secrets?.refreshAccessToken(this.clientId);
this.retryQueuedRequests();
} catch (refreshError) {
console.error("Failed to refresh token:", refreshError);
// Optionally, clear the queue on failure to prevent hanging requests
this.requestQueue = [];
} finally {
this.awaitingRefresh = false;
}
}
// Re-queue the failed request for retry after refresh
return new Promise((resolve, reject) => {
this.requestQueue.push(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.axiosClient.request(error.config!).then(resolve).catch(reject);
});
});
}
if (error.response) {
throw new CalApiError(error.response.statusText, error.response.status);
} else if (error.request) {
throw new Error("The request was made but no response was received");
} else {
throw new Error("An error occurred during the request setup");
}
}
);
}
async post<T>(endpoint: Endpoints, options: CallOptionsWithBody): Promise<T> {
const { data } = await this.axiosClient.post<T>(
this.createCallUrl(endpoint, options.urlParams),
options.body,
this.wrapConfigWithAuth(endpoint, options.config)
);
return data;
}
async get<T>(endpoint: Endpoints, options?: CallOptions): Promise<T> {
const { data } = await this.axiosClient.get<T>(
this.createCallUrl(endpoint, options?.urlParams),
this.wrapConfigWithAuth(endpoint, options?.config)
);
return data;
}
async delete<T>(endpoint: Endpoints, options?: CallOptions): Promise<T> {
const { data } = await this.axiosClient.delete<T>(
this.createCallUrl(endpoint, options?.urlParams),
this.wrapConfigWithAuth(endpoint, options?.config)
);
return data;
}
async patch<T>(endpoint: Endpoints, options: CallOptionsWithBody): Promise<T> {
const { data } = await this.axiosClient.patch<T>(
this.createCallUrl(endpoint, options.urlParams),
options.body,
this.wrapConfigWithAuth(endpoint, options.config)
);
return data;
}
private createCallUrl(endpoint: Endpoints, params?: Record<string, string> | string[]): string {
const { version, uri } = getEndpointData(endpoint, params);
return `${version}${uri}`;
}
private wrapConfigWithAuth(
endpoint: Endpoints,
config?: AxiosRequestConfig<unknown>
): AxiosRequestConfig<unknown> {
const { auth } = getEndpointDefinition(endpoint);
const headers: Record<string, unknown> = {};
const params: Record<string, unknown> = {};
if (this.secrets?.getAccessToken() && auth === "access_token") {
headers["Authorization"] = `Bearer ${this.secrets.getAccessToken()}`;
}
if (this.secrets?.getClientSecret() && auth === "secret") {
headers[X_CAL_SECRET_KEY] = this.secrets.getClientSecret();
params["clientId"] = this.clientId;
}
return merge(config, {
headers,
params,
});
}
isAwaitingRefresh() {
return this.awaitingRefresh;
}
}
@@ -0,0 +1,67 @@
import type { ExchangeCodeResponse } from "../endpoints/oauth-flow/types";
import type { BasicPlatformResponse } from "../types";
import { Endpoints } from "./endpoints";
import type { HttpCaller } from "./http-caller";
import { X_CAL_SECRET_KEY } from "./http-caller";
export class SdkSecrets {
private refreshedAt: Date | null;
constructor(
private readonly clientSecret: string,
private accessToken: string,
private refreshToken: string,
private readonly httpCaller: HttpCaller
) {
this.refreshedAt = null;
}
updateAccessToken(accessToken: string, refreshToken?: string) {
this.accessToken = accessToken;
if (refreshToken) {
this.refreshToken = refreshToken;
}
}
async refreshAccessToken(clientId: string) {
const { data } = await this.httpCaller.post<BasicPlatformResponse<ExchangeCodeResponse>>(
Endpoints.REFRESH_OAUTH_TOKEN,
{
urlParams: [clientId],
body: {
refreshToken: this.refreshToken,
},
config: {
headers: {
[X_CAL_SECRET_KEY]: this.clientSecret,
},
},
}
);
this.accessToken = data.accessToken;
this.refreshToken = data.refreshToken;
this.refreshedAt = new Date();
}
public getAccessToken(): Readonly<string> {
return this.accessToken;
}
public getClientSecret(): Readonly<string> {
return this.clientSecret;
}
public isAccessTokenSet(): boolean {
return !!this.accessToken;
}
public _debug() {
return {
refreshedAt: this.refreshedAt,
accessToken: this.accessToken,
};
}
}
+33
View File
@@ -0,0 +1,33 @@
export interface CalSdkConstructorOptions {
baseUrl?: string;
handleRefresh?: boolean;
httpRetries?: CalSdkHttpRetries;
}
interface CalSdkHttpRetries {
maxAmount: number;
}
export type ResponseStatus = "success" | "error";
export interface BasicPlatformResponse<T = unknown> {
status: ResponseStatus;
data: T;
}
export enum ApiVersion {
V1 = "v1/",
V2 = "v2/",
NEUTRAL = "/",
}
export type SdkAuthOptions = {
clientSecret: string;
accessToken?: string;
refreshToken?: string;
};
export * from "./endpoints/slots/types";
export * from "./endpoints/schedules/types";
export * from "./endpoints/events/types";
export * from "./endpoints/oauth-flow/types";
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "ESNext"
},
"exclude": ["src/__tests__/**/*", "example/*"]
}
+109
View File
@@ -0,0 +1,109 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs" /* Specify what module code is generated. */,
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
/* Type Checking */
"strict": true /* Enable all strict type-checking options. */,
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}
+1857 -62
View File
File diff suppressed because it is too large Load Diff