api: add first version of export api
This commit is contained in:
committed by
Carl-Gerhard Lindesvärd
parent
7ea95afe16
commit
7f8d857508
88
apps/api/src/controllers/export.controller.ts
Normal file
88
apps/api/src/controllers/export.controller.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
import type { GetEventListOptions } from '@openpanel/db';
|
||||||
|
import { ClientType, db, getEventList, getEventsCount } from '@openpanel/db';
|
||||||
|
|
||||||
|
type EventsQuery = {
|
||||||
|
project_id?: string;
|
||||||
|
event?: string | string[];
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
page?: string;
|
||||||
|
};
|
||||||
|
export async function events(
|
||||||
|
request: FastifyRequest<{
|
||||||
|
Querystring: EventsQuery;
|
||||||
|
}>,
|
||||||
|
reply: FastifyReply
|
||||||
|
) {
|
||||||
|
const query = request.query;
|
||||||
|
|
||||||
|
if (query.project_id) {
|
||||||
|
if (
|
||||||
|
request.client?.type === ClientType.read &&
|
||||||
|
request.client?.projectId !== query.project_id
|
||||||
|
) {
|
||||||
|
reply.status(403).send({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'You do not have access to this project',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: {
|
||||||
|
organizationSlug: request.client?.organizationSlug,
|
||||||
|
id: query.project_id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
reply.status(404).send({
|
||||||
|
error: 'Not Found',
|
||||||
|
message: 'Project not found',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = query.project_id ?? request.client?.projectId;
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
reply.status(400).send({
|
||||||
|
error: 'Bad Request',
|
||||||
|
message: 'project_id is required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursor = (parseInt(query.page || '1', 10) || 1) - 1;
|
||||||
|
const options: GetEventListOptions = {
|
||||||
|
projectId,
|
||||||
|
events: (Array.isArray(query.event) ? query.event : [query.event]).filter(
|
||||||
|
(s): s is string => typeof s === 'string'
|
||||||
|
),
|
||||||
|
startDate: query.start ? new Date(query.start) : undefined,
|
||||||
|
endDate: query.end ? new Date(query.end) : undefined,
|
||||||
|
cursor,
|
||||||
|
take: 50,
|
||||||
|
meta: false,
|
||||||
|
profile: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [data, totalCount] = await Promise.all([
|
||||||
|
getEventList(options),
|
||||||
|
getEventsCount(options),
|
||||||
|
]);
|
||||||
|
|
||||||
|
reply.send({
|
||||||
|
meta: {
|
||||||
|
// options,
|
||||||
|
count: data.length,
|
||||||
|
totalCount: totalCount,
|
||||||
|
pages: Math.ceil(totalCount / options.take),
|
||||||
|
current: cursor + 1,
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
|
|
||||||
|
import type { IServiceClient } from '@openpanel/db';
|
||||||
import { redisPub } from '@openpanel/redis';
|
import { redisPub } from '@openpanel/redis';
|
||||||
|
|
||||||
import eventRouter from './routes/event.router';
|
import eventRouter from './routes/event.router';
|
||||||
|
import exportRouter from './routes/export.router';
|
||||||
import liveRouter from './routes/live.router';
|
import liveRouter from './routes/live.router';
|
||||||
import miscRouter from './routes/misc.router';
|
import miscRouter from './routes/misc.router';
|
||||||
import profileRouter from './routes/profile.router';
|
import profileRouter from './routes/profile.router';
|
||||||
@@ -12,6 +14,7 @@ import { logger, logInfo } from './utils/logger';
|
|||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
interface FastifyRequest {
|
interface FastifyRequest {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
client: IServiceClient | null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +36,7 @@ const startServer = async () => {
|
|||||||
fastify.register(profileRouter, { prefix: '/profile' });
|
fastify.register(profileRouter, { prefix: '/profile' });
|
||||||
fastify.register(liveRouter, { prefix: '/live' });
|
fastify.register(liveRouter, { prefix: '/live' });
|
||||||
fastify.register(miscRouter, { prefix: '/misc' });
|
fastify.register(miscRouter, { prefix: '/misc' });
|
||||||
|
fastify.register(exportRouter, { prefix: '/export' });
|
||||||
fastify.setErrorHandler((error) => {
|
fastify.setErrorHandler((error) => {
|
||||||
fastify.log.error(error);
|
fastify.log.error(error);
|
||||||
});
|
});
|
||||||
|
|||||||
37
apps/api/src/routes/export.router.ts
Normal file
37
apps/api/src/routes/export.router.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import * as controller from '@/controllers/export.controller';
|
||||||
|
import { validateExportRequest } from '@/utils/auth';
|
||||||
|
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||||
|
|
||||||
|
import { Prisma } from '@openpanel/db';
|
||||||
|
|
||||||
|
const eventRouter: FastifyPluginCallback = (fastify, opts, done) => {
|
||||||
|
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
|
||||||
|
try {
|
||||||
|
const client = await validateExportRequest(req.headers);
|
||||||
|
req.client = client;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
return reply.status(401).send({
|
||||||
|
error: 'Unauthorized',
|
||||||
|
message: 'Client ID seems to be malformed',
|
||||||
|
});
|
||||||
|
} else if (e instanceof Error) {
|
||||||
|
return reply
|
||||||
|
.status(401)
|
||||||
|
.send({ error: 'Unauthorized', message: e.message });
|
||||||
|
}
|
||||||
|
return reply
|
||||||
|
.status(401)
|
||||||
|
.send({ error: 'Unauthorized', message: 'Unexpected error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.route({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/events',
|
||||||
|
handler: controller.events,
|
||||||
|
});
|
||||||
|
done();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default eventRouter;
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { RawRequestDefaultExpression } from 'fastify';
|
import type { RawRequestDefaultExpression } from 'fastify';
|
||||||
|
|
||||||
import { verifyPassword } from '@openpanel/common';
|
import { verifyPassword } from '@openpanel/common';
|
||||||
import { db } from '@openpanel/db';
|
import type { IServiceClient } from '@openpanel/db';
|
||||||
|
import { ClientType, db } from '@openpanel/db';
|
||||||
|
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
|
||||||
@@ -89,5 +90,39 @@ export async function validateSdkRequest(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!client.projectId) {
|
||||||
|
throw new Error('No project id found for client');
|
||||||
|
}
|
||||||
|
|
||||||
return client.projectId;
|
return client.projectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function validateExportRequest(
|
||||||
|
headers: RawRequestDefaultExpression['headers']
|
||||||
|
): Promise<IServiceClient> {
|
||||||
|
const clientId = headers['openpanel-client-id'] as string;
|
||||||
|
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
|
||||||
|
const client = await db.client.findUnique({
|
||||||
|
where: {
|
||||||
|
id: clientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
throw new Error('Export: Invalid client id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.secret) {
|
||||||
|
throw new Error('Export: Client has no secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client.type === ClientType.write) {
|
||||||
|
throw new Error('Export: Client is not allowed to export');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await verifyPassword(clientSecret, client.secret))) {
|
||||||
|
throw new Error('Export: Invalid client secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,5 +19,6 @@
|
|||||||
},
|
},
|
||||||
"javascript": "Javascript SDK",
|
"javascript": "Javascript SDK",
|
||||||
"web": "Web SDK",
|
"web": "Web SDK",
|
||||||
"api": "API"
|
"api": "API",
|
||||||
|
"export": "Export"
|
||||||
}
|
}
|
||||||
|
|||||||
21
apps/docs/src/pages/docs/export.mdx
Normal file
21
apps/docs/src/pages/docs/export.mdx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Export API
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
To authenticate with the Export API, you need to use your `clientId` and `clientSecret`. Make sure your client has `read` or `root` mode. The default client does not have access to the Export API.
|
||||||
|
|
||||||
|
We expect you to send `openpanel-client-id` and `openpanel-client-secret` headers with your requests.
|
||||||
|
|
||||||
|
## Events
|
||||||
|
|
||||||
|
Get all `screen_view` events from project `abc` between `2024-04-15` and `2024-04-18`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'https://api.openpanel.dev/export/events?project_id=abc&event=screen_view&start=2024-04-15&end=2024-04-18' \
|
||||||
|
-H 'openpanel-client-id: CLIENT_ID' \
|
||||||
|
-H 'openpanel-client-secret: CLIENT_SECRET'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Profiles
|
||||||
|
|
||||||
|
During development
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ClientType" AS ENUM ('read', 'write', 'root');
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "clients" DROP CONSTRAINT "clients_projectId_fkey";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "clients" ADD COLUMN "type" "ClientType" NOT NULL DEFAULT 'read',
|
||||||
|
ALTER COLUMN "projectId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "clients" ADD CONSTRAINT "clients_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "clients" ALTER COLUMN "type" SET DEFAULT 'write';
|
||||||
@@ -90,14 +90,21 @@ model Profile {
|
|||||||
@@map("profiles")
|
@@map("profiles")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ClientType {
|
||||||
|
read
|
||||||
|
write
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
model Client {
|
model Client {
|
||||||
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
name String
|
name String
|
||||||
secret String?
|
secret String?
|
||||||
projectId String
|
type ClientType @default(write)
|
||||||
project Project @relation(fields: [projectId], references: [id])
|
projectId String?
|
||||||
|
project Project? @relation(fields: [projectId], references: [id])
|
||||||
organizationSlug String
|
organizationSlug String
|
||||||
cors String @default("*")
|
cors String @default("*")
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|||||||
@@ -248,6 +248,10 @@ export interface GetEventListOptions {
|
|||||||
cursor?: number;
|
cursor?: number;
|
||||||
events?: string[] | null;
|
events?: string[] | null;
|
||||||
filters?: IChartEventFilter[];
|
filters?: IChartEventFilter[];
|
||||||
|
startDate?: Date;
|
||||||
|
endDate?: Date;
|
||||||
|
meta?: boolean;
|
||||||
|
profile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEventList({
|
export async function getEventList({
|
||||||
@@ -257,6 +261,10 @@ export async function getEventList({
|
|||||||
profileId,
|
profileId,
|
||||||
events,
|
events,
|
||||||
filters,
|
filters,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
meta = true,
|
||||||
|
profile = true,
|
||||||
}: GetEventListOptions) {
|
}: GetEventListOptions) {
|
||||||
const { sb, getSql, join } = createSqlBuilder();
|
const { sb, getSql, join } = createSqlBuilder();
|
||||||
|
|
||||||
@@ -268,6 +276,10 @@ export async function getEventList({
|
|||||||
sb.where.deviceId = `device_id IN (SELECT device_id as did FROM events WHERE profile_id = ${escape(profileId)} group by did)`;
|
sb.where.deviceId = `device_id IN (SELECT device_id as did FROM events WHERE profile_id = ${escape(profileId)} group by did)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (startDate && endDate) {
|
||||||
|
sb.where.created_at = `created_at BETWEEN '${formatClickhouseDate(startDate)}' AND '${formatClickhouseDate(endDate)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
if (events && events.length > 0) {
|
if (events && events.length > 0) {
|
||||||
sb.where.events = `name IN (${join(
|
sb.where.events = `name IN (${join(
|
||||||
events.map((event) => escape(event)),
|
events.map((event) => escape(event)),
|
||||||
@@ -288,7 +300,7 @@ export async function getEventList({
|
|||||||
|
|
||||||
sb.orderBy.created_at = 'created_at DESC';
|
sb.orderBy.created_at = 'created_at DESC';
|
||||||
|
|
||||||
return getEvents(getSql(), { profile: true, meta: true });
|
return getEvents(getSql(), { profile, meta });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getEventsCount({
|
export async function getEventsCount({
|
||||||
@@ -296,6 +308,8 @@ export async function getEventsCount({
|
|||||||
profileId,
|
profileId,
|
||||||
events,
|
events,
|
||||||
filters,
|
filters,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
}: Omit<GetEventListOptions, 'cursor' | 'take'>) {
|
}: Omit<GetEventListOptions, 'cursor' | 'take'>) {
|
||||||
const { sb, getSql, join } = createSqlBuilder();
|
const { sb, getSql, join } = createSqlBuilder();
|
||||||
sb.where.projectId = `project_id = ${escape(projectId)}`;
|
sb.where.projectId = `project_id = ${escape(projectId)}`;
|
||||||
@@ -303,6 +317,10 @@ export async function getEventsCount({
|
|||||||
sb.where.profileId = `profile_id = ${escape(profileId)}`;
|
sb.where.profileId = `profile_id = ${escape(profileId)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (startDate && endDate) {
|
||||||
|
sb.where.created_at = `created_at BETWEEN '${formatClickhouseDate(startDate)}' AND '${formatClickhouseDate(endDate)}'`;
|
||||||
|
}
|
||||||
|
|
||||||
if (events && events.length > 0) {
|
if (events && events.length > 0) {
|
||||||
sb.where.events = `name IN (${join(
|
sb.where.events = `name IN (${join(
|
||||||
events.map((event) => escape(event)),
|
events.map((event) => escape(event)),
|
||||||
|
|||||||
Reference in New Issue
Block a user