rebase fixing conflicts

pull/9078/head
Agusti Fernandez Pardo 2022-03-23 22:22:57 +01:00
parent 11e1312e3a
commit 9a5c3f96ec
22 changed files with 694 additions and 56 deletions

6
.env.local.example Normal file
View File

@ -0,0 +1,6 @@
NEXTAUTH_URL=http://localhost:3000 // This requires you run also app.cal.com in localhost:3000 to re-use it's Next-Auth api endpoints.
NEXTAUTH_SECRET=hello
EMAIL_SERVER='smtp://localhost:587'
EMAIL_FROM=Cal.com <noreply@example.com>
DATABASE_URL="postgresql://postgres:@localhost:5450/calendso"

6
.gitignore vendored
View File

@ -1,4 +1,10 @@
dist
node_modules/
.env
<<<<<<< HEAD
.next
=======
.next/
.turbo/
.tsconfig.tsbuildinfo
>>>>>>> 5a71055 (feat: Initial work on event-types, add jest for testing w node-http-mocks)

105
README.md
View File

@ -1 +1,106 @@
<<<<<<< HEAD
# Cal.com Public API (Enterprise Only)
=======
# Public API for Cal.com
## This will be the new public enterprise-only API
It will be a REST API for now, we might want to look into adding a GraphQL endpoint way down the roadmap if it makes sense. For now we think REST should cover what developers need.
## NextJS + TypeScript
It's a barebones **NextJS** + **TypeScript** project leveraging the nextJS API with a pages/api folder.
## NextAuth
Using the new next-auth middleware getToken and getSession for our API auth and any other middleware check we might want to do like user role. enterprise status, api req limit, etc.
The idea is to leverage current `Session` accessToken. next-auth reads the Authorization Header and if it contains a valid JWT Bearer Token, it decodes it.
We'll also need the EmailProvider (to expose VerificationTokens generation) and prisma adapter (to connect with the database)
We will extend it to also be valids PAT's with longer than web auth sessions expiryDates (maybe never expire).
## No react
It doesn't have react or react-dom as a dependency, and will only be used by a redirect as a folder or subdomain on cal.com with maybe a v1 tag like:
- `v1.api.cal.com`
- `api.cal.com/v1`
- `app.cal.com/api/v1/`
## Example
HTTP Request (Use Paw.app/postman/postwoman/hoppscotch)
```
POST /api/jwt HTTP/1.1
authorization: Bearer eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..ik9ibWdgN2Mq-WYH.7qsAwcOtOQyqwjIQ03EkEHy4kpy4GAndbqqQlhczc9xRgn_ycqXn4RbmwWA9LGm2LIXp_MQXMNm-i5vvc7piGZYyTPIGTieLspCYG4CKnZIawjcXmBEiwG9-PafNSUOGJB1O41l-9WbOEZNnIIAlfBTxdM3T13fUP4ese348tbn755Vi27Q_hOKulOfJ-Z-IQCd1OMsmTbuBo537IUkpj979.y288909Yt7mEYWJUAJRqdQ
```
or with cURL
```
curl -X "POST" "http://localhost:3002/api/jwt" \
-H 'authorization: Bearer eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIn0..ik9ibWdgN2Mq-WYH.7qsAwcOtOQyqwjIQ03EkEHy4kpy4GAndbqqQlhczc9xRgn_ycqXn4RbmwWA9LGm2LIXp_MQXMNm-i5vvc7piGZYyTPIGTieLspCYG4CKnZIawjcXmBEiwG9-PafNSUOGJB1O41l-9WbOEZNnIIAlfBTxdM3T13fUP4ese348tbn755Vi27Q_hOKulOfJ-Z-IQCd1OMsmTbuBo537IUkpj979.y288909Yt7mEYWJUAJRqdQ'
```
Returns:
```{
"name": null,
"email": "m@n.es",
"sub": "cl032mhik0006w4ylrtay2t3f",
"iat": 1645894473,
"exp": 1648486473,
"jti": "af1c04f2-09a8-45b5-a6f0-c35eea9efa9b",
"userRole": "admin"
}
```
## API Endpoint Validation
The API uses `zod` library like our main web repo. It validates that either GET query parameters or POST body content's are valid and up to our spec. It gives appropiate errors when parsing result's with schemas.
## Testing
/event-types
GET
/teams
teams/join
GET / PATCH / PUT /users/:id
/users/new
POST
/event-types
/bookings
/availabilties
/schedules
## Users
GET /users : Get all users you're an owner / team manager of. Requires Auth.
POST /users : Create a new user
GET /users/{id} : Get the user information identified by "id"
PUT /users/{id} : Update the user information identified by "id"
DELETE /users/{id} : Delete user by "id"
## Event Types
/event-types
GET /event-types : Get all event-types
POST /event-types : Create a new user
GET /event-types/{id} : Get the user information identified by "id"
PUT /event-types/{id} : Update the user information identified by "id"
DELETE /event-types/{id} : Delete user by "id"
## Bookings
/bookings
>>>>>>> 5a71055 (feat: Initial work on event-types, add jest for testing w node-http-mocks)

View File

@ -0,0 +1,22 @@
import { createMocks } from "node-mocks-http";
import prisma from "@calcom/prisma";
import { EventType } from "@calcom/prisma/client";
import handleEvent from "../pages/api/event-types/[id]";
describe("/api/event-types/[id]", () => {
it("returns a message with the specified events", async () => {
const { req, res } = createMocks({
method: "GET",
query: {
id: 1,
},
});
prisma.eventType.findUnique({ where: { id: 1 } }).then(async (data: EventType) => {
await handleEvent(req, res);
expect(res._getStatusCode()).toBe(200);
expect(JSON.parse(res._getData())).toStrictEqual({ event: data });
});
});
});

3
babel.config.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
presets: [["@babel/preset-env", { targets: { node: "current" } }], "@babel/preset-typescript"],
};

194
jest.config.ts Normal file
View File

@ -0,0 +1,194 @@
/*
* For a detailed explanation regarding each configuration property and type check, visit:
* https://jestjs.io/docs/en/configuration.html
*/
export default {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/fl/sw088bcs0dx4zyy0f_ghzvwc0000gn/T/jest_dx",
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
coverageDirectory: "coverage",
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// Indicates which provider should be used to instrument code for coverage
// coverageProvider: "babel",
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "json",
// "jsx",
// "ts",
// "tsx",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: undefined,
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state between every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state between every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
testEnvironment: "node",
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jasmine2",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: undefined,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
};

16
next.config.js Normal file
View File

@ -0,0 +1,16 @@
module.exports = {
async rewrites() {
return [
// This redirects requests recieved at / the root to the /api/ folder.
{
source: "/:rest*",
destination: "/api/:rest*",
},
// This redirects requests to api/v*/ to /api/ passing version as a query parameter.
{
source: "/api/v:version/:rest*",
destination: "/api/:rest*?version=:version",
},
];
},
};

View File

@ -11,17 +11,27 @@
"start": "next start",
"build": "next build",
"lint": "next lint",
"test": "jest",
"type-check": "tsc --pretty --noEmit",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
},
"devDependencies": {
"@babel/core": "^7.17.8",
"@babel/preset-env": "^7.16.11",
"@babel/preset-typescript": "^7.16.7",
"@calcom/prisma": "*",
"@calcom/tsconfig": "*",
"scripts": "*",
"typescript": "^4.5.3"
"babel-jest": "^27.5.1",
"install": "^0.13.0",
"jest": "^27.5.1",
"node-mocks-http": "^1.11.0",
"npm": "^8.5.5",
"typescript": "^4.5.3",
"zod": "^3.14.2"
},
"dependencies": {
"@next-auth/prisma-adapter": "^1.0.1",
"next": "^12.1.0"
"next": "^12.1.0",
"next-auth": "^4.3.1"
}
}

20
pages/api/_middleware.ts Normal file
View File

@ -0,0 +1,20 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getToken } from "next-auth/jwt";
import { NextRequest, NextResponse } from "next/server";
export async function middleware(req: NextApiRequest) {
// return early if url isn't supposed to be protected
// if (!req.url.includes("/protected-url")) {
// return NextResponse.next()
// }
console.log(req.headers);
const session = await getToken({ req, secret: process.env.SECRET });
// You could also check for any property on the session object,
// like role === "admin" or name === "John Doe", etc.
if (!session) {
return NextResponse.redirect("https://localhost:3002/unauthorized");
}
// If user is authenticated, continue.
return NextResponse.next();
}

View File

@ -0,0 +1,82 @@
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { PrismaClient } from "@prisma/client";
import NextAuth, { NextAuthOptions } from "next-auth";
import EmailProvider from "next-auth/providers/email";
// FIXME: not working when importing prisma directly from our project
// import prisma from "@calcom/prisma";
const prisma = new PrismaClient();
// TODO: get rid of this and do it in it's own auth.cal.com project with a custom Next.js app
export const authOptions: NextAuthOptions = {
// For more information on each option (and a full list of options) go to
// https://next-auth.js.org/configuration/options
adapter: PrismaAdapter(prisma),
// https://next-auth.js.org/configuration/providers
providers: [
EmailProvider({
maxAge: 10 * 60 * 60, // Magic links are valid for 10 min only
// sendVerificationRequest,
}),
],
secret: process.env.NEXTAUTH_SECRET,
session: {
// Use JSON Web Tokens for session instead of database sessions.
// This option can be used with or without a database for users/accounts.
// Note: `strategy` should be set to 'jwt' if no database is used.
// TODO: Do we want to move 'database' sessions at some point?
strategy: "jwt",
// Seconds - How long until an idle session expires and is no longer valid.
// maxAge: 30 * 24 * 60 * 60, // 30 days
// Seconds - Throttle how frequently to write to database to extend a session.
// Use it to limit write operations. Set to 0 to always update the database.
// Note: This option is ignored if using JSON Web Tokens
// updateAge: 24 * 60 * 60, // 24 hours
},
// JSON Web tokens are only used for sessions if the `strategy: 'jwt'` session
// option is set - or by default if no database is specified.
// https://next-auth.js.org/configuration/options#jwt
jwt: {
// A secret to use for key generation (you should set this explicitly)
secret: process.env.SECRET,
// Set to true to use encryption (default: false)
// encryption: true,
// You can define your own encode/decode functions for signing and encryption
// if you want to override the default behaviour.
// encode: async ({ secret, token, maxAge }) => {},
// decode: async ({ secret, token, maxAge }) => {},
},
// https://next-auth.js.org/configuration/pages
// NOTE: We don't want to enable these, only the API endpoints for auth. We will get rid of this when we do auth.cal.com
pages: {
signIn: "/", // Displays signin buttons
signOut: "/", // Displays form with sign out button
error: "/", // Error code passed in query string as ?error=
verifyRequest: "/", // Used for check email page
newUser: "/", // If set, new users will be directed here on first sign in
},
// Callbacks are asynchronous functions you can use to control what happens
// when an action is performed.
// https://next-auth.js.org/configuration/callbacks
callbacks: {
// async signIn({ user, account, profile, email, credentials }) { return true },
// async redirect({ url, baseUrl }) { return baseUrl },
// async session({ session, token, user }) { return session },
// FIXME: add a custom jwt callback, that is stored outside next-auth
// and can be reused to generate valid Personal Access Tokens for the API.
// async jwt({ token, user, account, profile, isNewUser }) { return token }
},
// Events are useful for logging
// https://next-auth.js.org/configuration/events
// Enable debug messages in the console if you are having problems
debug: false,
};
export default NextAuth(authOptions);

View File

@ -0,0 +1,19 @@
import { EventType } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
type EventIdData = {
event?: EventType;
error?: any;
};
export default async function eventType(req: NextApiRequest, res: NextApiResponse<EventIdData>) {
try {
const event = await prisma.eventType.findUnique({ where: { id: Number(req.query.id) } });
res.status(200).json({ event });
} catch (error) {
console.log(error);
res.status(400).json({ error: error });
}
}

View File

@ -0,0 +1,20 @@
import { PrismaClient, EventType } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
type EventTypeIdData = {
events?: EventType[];
error?: any;
};
const prisma = new PrismaClient();
export default async function eventType(req: NextApiRequest, res: NextApiResponse<EventTypeIdData>) {
try {
const eventTypes = await prisma.eventType.findMany({ where: { id: Number(req.query.eventTypeId) } });
res.status(200).json({ events: { ...eventTypes } });
} catch (error) {
console.log(error);
// FIXME: Add zod for validation/error handling
res.status(400).json({ error: error });
}
}

View File

@ -0,0 +1,85 @@
import { PrismaClient, EventType } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
const prisma = new PrismaClient();
interface Body {
userId: string;
newOrganization: {
name: string;
users: string[];
};
}
type EventsData = {
event?: EventType;
error?: string;
};
export default async function createEventLink(req: NextApiRequest, res: NextApiResponse<EventsData>) {
const {
body: {
title,
slug,
description,
position,
locations,
hidden,
teamId,
eventName,
timeZone,
periodType,
periodStartDate,
periodEndDate,
periodDays,
periodCountCalendarDays,
requiresConfirmation,
disableGuests,
minimumBookingNotice,
beforeEventBuffer,
afterEventBuffer,
schedulingType,
price,
currency,
slotInterval,
metadata,
length,
},
method,
} = req;
if (method === "POST") {
// Process a POST request
const newEvent = await prisma.eventType.create({
data: {
title: `${title}`,
slug: `${slug}`,
length: Number(length),
// description: description as string,
// position: Number(position),
// locations: locations,
// hidden: Boolean(hidden) as boolean,
// teamId: Number.isInteger(teamId) ? Number(teamId) : null,
// eventName: eventName,
// timeZone: timeZone,
// periodType: periodType,
// periodStartDate: periodStartDate,
// periodEndDate: periodEndDate,
// periodDays: periodDays,
// periodCountCalendarDays: periodCountCalendarDays,
// requiresConfirmation: requiresConfirmation,
// disableGuests: disableGuests,
// minimumBookingNotice: minimumBookingNotice,
// beforeEventBuffer: beforeEventBuffer,
// afterEventBuffer: afterEventBuffer,
// schedulingType: schedulingType,
// price: price,
// currency: currency,
// slotInterval: slotInterval,
// metadata: metadata,
},
});
res.status(201).json({ event: newEvent });
} else {
// Handle any other HTTP method
res.status(405).json({ error: "Only POST Method allowed" });
}
}

8
pages/api/jwt.ts Normal file
View File

@ -0,0 +1,8 @@
import type { NextApiRequest, NextApiResponse } from "next"
import { getToken } from "next-auth/jwt";
const secret = process.env.NEXTAUTH_SECRET;
export default async function jwt(req: NextApiRequest, res: NextApiResponse) {
const token = await getToken({ req, secret });
res.send(JSON.stringify(token, null, 2));
}

36
pages/api/me/index.ts Normal file
View File

@ -0,0 +1,36 @@
import { PrismaClient } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { getToken } from "next-auth/jwt";
// This local import doesn't work
// import { PrismaClient } from "@calcom/prisma";
const prisma = new PrismaClient();
const secret = process.env.NEXTAUTH_SECRET;
type Data = {
message: string;
};
export default async function me(req: NextApiRequest, res: NextApiResponse<Data>) {
const token = await getToken({ req, secret, raw: false });
console.log("token", token);
if (!token)
return res.status(404).json({
message: `You're not authenticated. Provide a valid Session JWT as Authorization: 'Bearer {your_token_here}'`,
});
if (!token.email)
return res.status(404).json({
message: `Your token doesn't have a valid email`,
});
const email: string | undefined = token?.email;
const user = await prisma.user.findUnique({
where: {
email: email,
},
});
return res.json({
message: `Hello ${user?.name}, your email is ${user?.email}, and your email is ${
user?.emailVerified ? "verified" : "unverified"
}`,
});
}

19
pages/api/protected.ts Normal file
View File

@ -0,0 +1,19 @@
// This is an example of how to read a JSON Web Token from an API route
import type { NextApiRequest, NextApiResponse } from "next";
import { getToken } from "next-auth/jwt";
const secret = process.env.NEXTAUTH_SECRET;
export default async function jwt(req: NextApiRequest, res: NextApiResponse) {
const token = await getToken({ req, secret, raw: false });
if (token) {
res.send({
content: "This is protected content. You can access this content because you are signed in.",
token,
});
} else {
res.send({
error: "You must be signed in to view the protected content on this page.",
});
}
}

14
pages/api/session.ts Normal file
View File

@ -0,0 +1,14 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth";
import { authOptions } from "./auth/[...nextauth]";
export default async function session(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession({ req, res }, authOptions);
/* ... */
if (session) {
res.send(JSON.stringify(session, null, 2));
} else {
res.end();
}
}

21
pages/api/users/[id].ts Normal file
View File

@ -0,0 +1,21 @@
import { PrismaClient } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
// This local import doesn't work
// import { PrismaClient } from "@calcom/prisma";
const prisma = new PrismaClient();
type Data = {
message: string;
};
export default async function userId(req: NextApiRequest, res: NextApiResponse<Data>) {
const { id }: { id?: string } = req.query;
if (!id) return res.status(404).json({ message: `User not found` });
const user = await prisma.user.findUnique({
where: {
id: parseInt(id),
},
});
return res.json({ message: `Hello ${user?.name}` });
}

View File

@ -1,12 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next"
import { getToken } from "next-auth/jwt";
const secret = process.env.NEXTAUTH_SECRET;
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// if using `NEXTAUTH_SECRET` env variable, we detect it, and you won't actually need to `secret`
// const token = await getToken({ req })
const token = await getToken({ req, secret, raw: true });
console.log("JSON Web Token", token);
res.end()
}

View File

@ -1,18 +0,0 @@
// This is an example of to protect an API route
import { getSession } from "next-auth/react"
import type { NextApiRequest, NextApiResponse } from "next"
export default async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getSession({ req })
if (session) {
res.send({
content:
"This is protected content. You can access this content because you are signed in.",
})
} else {
res.send({
error: "You must be signed in to view the protected content on this page.",
})
}
};

View File

@ -1,8 +0,0 @@
// This is an example of how to access a session from an API route
import { getSession } from "next-auth/react"
import type { NextApiRequest, NextApiResponse } from "next"
export default async (req: NextApiRequest, res: NextApiResponse) => {
const session = await getSession({ req })
res.send(JSON.stringify(session, null, 2))
}

View File

@ -1,23 +1,13 @@
{
"extends": "@calcom/tsconfig/base.json",
"compilerOptions": {
"lib": [
"ES2015"
],
"module": "CommonJS",
"outDir": "./dist",
"rootDir": "./src",
"target": "es5",
"allowJs": true,
"noEmit": true,
"incremental": true,
"resolveJsonModule": true,
"jsx": "preserve"
},
"exclude": [
"node_modules"
],
"compilerOptions": {
"strictNullChecks": true,
"baseUrl": "./",
},
"include": [
"src"
"./pages/api/*.ts"
]
}