Projects STRLCPY gradejs Commits 2cb48da3
🤬
  • ■ ■ ■ ■ ■
    packages/public-api/package.json
    skipped 5 lines
    6 6   "prettier": "prettier --check ./src",
    7 7   "typecheck": "tsc --project tsconfig.build.json --noEmit",
    8 8   "build": "tsc --project tsconfig.build.json",
    9  - "test": "CORS_ALLOWED_ORIGIN=http://localhost:3000 jest",
     9 + "test": "CORS_ALLOWED_ORIGIN=http://test jest",
    10 10   "start": "node build/index.js",
    11 11   "debug": "node --inspect=9201 build/index.js"
    12 12   },
    skipped 3 lines
    16 16   "license": "MIT",
    17 17   "dependencies": {
    18 18   "@gradejs-public/shared": "^0.1.0",
    19  - "@trpc/server": "^9.26.2",
    20 19   "@types/cors": "^2.8.12",
    21 20   "cors": "^2.8.5",
    22 21   "express": "^4.17.3",
    skipped 19 lines
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/app.ts
    1 1  import express from 'express';
    2  -import { createExpressMiddleware } from '@trpc/server/adapters/express';
    3 2  import { endpointMissingMiddleware, errorHandlerMiddleware, cors } from './middleware/common';
    4  -import { appRouter, createContext } from './router';
     3 +import healthCheckRouter from './healthcheck/router';
     4 +import websiteRouter from './website/router';
    5 5   
    6 6  export function createApp() {
    7 7   const app = express();
     8 + 
    8 9   app.use(cors);
     10 + app.use(express.json());
    9 11   
    10  - app.use(
    11  - '/',
    12  - createExpressMiddleware({
    13  - router: appRouter,
    14  - createContext,
    15  - })
    16  - );
     12 + // Routes
     13 + app.use(healthCheckRouter);
     14 + app.use(websiteRouter);
    17 15   
    18 16   // Error handling
    19 17   app.use(endpointMissingMiddleware);
    skipped 5 lines
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/healthcheck/router.test.ts
     1 +import { createSupertestApi } from '@gradejs-public/test-utils';
     2 +import { createApp } from '../app';
     3 + 
     4 +const api = createSupertestApi(createApp);
     5 + 
     6 +describe('GET /', () => {
     7 + it('should check origin', async () => {
     8 + await api.get('/').set('Origin', 'http://invalid').send().expect(400);
     9 + });
     10 + 
     11 + it('should return valid response', async () => {
     12 + await api.get('/').set('Origin', 'http://test').send().expect(200);
     13 + });
     14 + 
     15 + it('should return not found error', async () => {
     16 + const response = await api
     17 + .get('/any-invalid-route')
     18 + .set('Origin', 'http://test')
     19 + .send()
     20 + .expect(404);
     21 + 
     22 + expect(response.body).toMatchObject({
     23 + error: {
     24 + code: 404,
     25 + message: 'Not Found',
     26 + },
     27 + });
     28 + });
     29 +});
     30 + 
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/healthcheck/router.ts
     1 +import { StrictRouter } from '../middleware/strictRouter';
     2 + 
     3 +const strictRouter = new StrictRouter();
     4 + 
     5 +strictRouter.get('/', async () => {
     6 + return 'gradejs-public-api';
     7 +});
     8 + 
     9 +export default strictRouter.router;
     10 + 
  • ■ ■ ■ ■ ■
    packages/public-api/src/middleware/common.ts
    skipped 2 lines
    3 3  import { Request, Response, NextFunction } from 'express';
    4 4  import { NotFoundError, respondWithError } from './response';
    5 5  import { getCorsAllowedOrigins } from '@gradejs-public/shared';
     6 +export class CorsError extends Error {
     7 + code = 400;
     8 + message = 'Not allowed by CORS';
     9 +}
    6 10   
    7 11  const originAllowList = getCorsAllowedOrigins();
    8 12  export const cors = createCorsMiddleware({
    skipped 2 lines
    11 15   if (origin && originAllowList.includes(origin)) {
    12 16   callback(null, true);
    13 17   } else {
    14  - callback(new Error('Not allowed by CORS'));
     18 + callback(new CorsError());
    15 19   }
    16 20   },
    17 21  });
    skipped 16 lines
    34 38   _next: NextFunction
    35 39  ) {
    36 40   // Log only useful errors
    37  - if (!(error instanceof NotFoundError) && !(error instanceof ZodError)) {
     41 + if (
     42 + !(error instanceof NotFoundError) &&
     43 + !(error instanceof CorsError) &&
     44 + !(error instanceof ZodError)
     45 + ) {
    38 46   // TODO: add logger
    39 47   console.error(error, req);
    40 48   }
    skipped 4 lines
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/middleware/response.ts
     1 +import { API } from '@gradejs-public/shared';
    1 2  import { Response } from 'express';
    2 3  import { ZodError } from 'zod';
    3  - 
    4  -export type ApiResponse<TData = undefined> = { data: TData } | { error: ApiDetailedError };
    5  - 
    6  -export type ApiDetailedError = {
    7  - code: number;
    8  - message?: string;
    9  - param?: string;
    10  - type?: string;
    11  -};
     4 +import { CorsError } from './common';
    12 5   
    13 6  export class NotFoundError extends Error {
    14 7   code = 404;
    skipped 3 lines
    18 11  export function respond<T>(res: Response, data: T) {
    19 12   res.send({
    20 13   data,
    21  - } as ApiResponse<T>);
     14 + } as API.Response<T>);
    22 15  }
    23 16   
    24 17  export function respondWithError(res: Response, err: unknown) {
    25  - const error: ApiDetailedError = {
     18 + const error: API.Error = {
    26 19   code: 500,
    27 20   message: 'Internal server error, try again later',
    28 21   };
    29 22   
    30  - if (err instanceof NotFoundError) {
     23 + if (err instanceof NotFoundError || err instanceof CorsError) {
    31 24   error.code = err.code;
    32 25   error.message = err.message;
    33 26   }
    34 27   
    35 28   if (err instanceof ZodError) {
    36 29   const details = err.errors[0];
     30 + 
    37 31   error.code = 400;
    38 32   error.message = details.message || err.message;
    39 33   error.type = details.code;
    skipped 3 lines
    43 37   res.status(error.code);
    44 38   res.send({
    45 39   error,
    46  - } as ApiResponse);
     40 + } as API.Response);
    47 41  }
    48 42   
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/middleware/strictRouter.ts
     1 +import { Router, Request, Response, NextFunction } from 'express';
     2 +import { API } from '@gradejs-public/shared';
     3 +import { respond } from './response';
     4 + 
     5 +/**
     6 + * Express-like router with strict type guards
     7 + */
     8 +export class StrictRouter {
     9 + router: Router;
     10 + 
     11 + constructor() {
     12 + this.router = Router();
     13 + }
     14 + 
     15 + get<Route extends API.GetRoutes>(route: Route, handler: RouteHandler<Route, 'GET'>) {
     16 + this.handle('GET', route, handler);
     17 + }
     18 + 
     19 + post<Route extends API.PostRoutes>(route: Route, handler: RouteHandler<Route, 'POST'>) {
     20 + this.handle('POST', route, handler);
     21 + }
     22 + 
     23 + handle<Route extends API.Routes, Method extends string>(
     24 + method: Method,
     25 + route: Route,
     26 + handler: RouteHandler<Route, Method>
     27 + ) {
     28 + const expressHandler = async (
     29 + req: Parameters<typeof handler>[0],
     30 + res: Response,
     31 + next: NextFunction
     32 + ) => {
     33 + try {
     34 + const data = await handler(req);
     35 + respond(res, data);
     36 + } catch (e) {
     37 + next(e);
     38 + }
     39 + };
     40 + 
     41 + switch (method.toString()) {
     42 + case 'GET':
     43 + this.router.get(route, expressHandler);
     44 + break;
     45 + 
     46 + case 'POST':
     47 + this.router.post(route, expressHandler);
     48 + break;
     49 + }
     50 + }
     51 +}
     52 + 
     53 +type RouteHandler<Route extends API.Routes, Method extends string> =
     54 + Method extends keyof API.RouteMap[Route]
     55 + ? (
     56 + req: Request<
     57 + RouteProperty<Route, Method, 'params'>,
     58 + RouteProperty<Route, Method, 'response'>,
     59 + RouteProperty<Route, Method, 'body'>,
     60 + RouteProperty<Route, Method, 'query'>
     61 + >
     62 + ) => Promise<RouteProperty<Route, Method, 'response'>>
     63 + : never;
     64 + 
     65 +type RouteProperty<Route extends API.Routes, Method extends string, PropName> =
     66 + Method extends keyof API.RouteMap[Route]
     67 + ? PropName extends keyof API.RouteMap[Route][Method]
     68 + ? API.RouteMap[Route][Method][PropName]
     69 + : never
     70 + : never;
     71 + 
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/middleware/validation.ts
     1 +import { z, ZodString } from 'zod';
     2 + 
     3 +type ZodLike<T> = { [K in keyof T]: T[K] extends string ? ZodString : never };
     4 + 
     5 +export function getRequestParams<Params>(request: { params: Params }, schema: ZodLike<Params>) {
     6 + return z.object(schema).parse(request.params);
     7 +}
     8 + 
     9 +export function getRequestBody<Body>(request: { body: Body }, schema: ZodLike<Body>) {
     10 + return z.object(schema).parse(request.body);
     11 +}
     12 + 
     13 +export function getRequestQuery<Query>(request: { query: Query }, schema: ZodLike<Query>) {
     14 + return z.object(schema).parse(request.query);
     15 +}
     16 + 
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/router.ts
    1  -import * as trpc from '@trpc/server';
    2  -// See also: https://colinhacks.com/essays/painless-typesafety
    3  -import { CreateExpressContextOptions } from '@trpc/server/adapters/express';
    4  -import { z, ZodError } from 'zod';
    5  -import { NotFoundError } from './middleware/response';
    6  -import {
    7  - getPackagesByHostname,
    8  - getWebPagesByHostname,
    9  - requestWebPageParse,
    10  - syncWebPage,
    11  -} from './website/service';
    12  -import { getAffectingVulnerabilities } from './vulnerabilities/vulnerabilities';
    13  -import { SerializableEntity, toSerializable } from '@gradejs-public/shared';
    14  - 
    15  -const hostnameRe =
    16  - /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/;
    17  - 
    18  -// created for each request
    19  -export const createContext = (_: CreateExpressContextOptions) => ({}); // no context
    20  -type Context = trpc.inferAsyncReturnType<typeof createContext>;
    21  - 
    22  -export namespace Api {
    23  - export type WebPage = SerializableEntity<
    24  - Awaited<ReturnType<typeof getWebPagesByHostname>>[number]
    25  - >;
    26  - export type WebPagePackage = SerializableEntity<
    27  - Awaited<ReturnType<typeof getPackagesByHostname>>[number]
    28  - >;
    29  - export type WebSiteParseResult = SerializableEntity<
    30  - Awaited<ReturnType<typeof requestWebPageParse>>
    31  - >;
    32  - export type Vulnerability = SerializableEntity<
    33  - Awaited<ReturnType<typeof getAffectingVulnerabilities>>[string][number]
    34  - >;
    35  -}
    36  - 
    37  -export const appRouter = trpc
    38  - .router<Context>()
    39  - .query('healthcheck', {
    40  - resolve() {
    41  - return 'gradejs-public-api';
    42  - },
    43  - })
    44  - .mutation('syncWebsite', {
    45  - input: z.string().regex(hostnameRe),
    46  - async resolve({ input: hostname }) {
    47  - const webpages = await getWebPagesByHostname(hostname);
    48  - 
    49  - if (webpages.length === 0) {
    50  - throw new NotFoundError();
    51  - }
    52  - 
    53  - await Promise.all(webpages.map(syncWebPage));
    54  - const packages = await getPackagesByHostname(hostname);
    55  - const vulnerabilities = await getAffectingVulnerabilities(packages);
    56  - 
    57  - return {
    58  - webpages: webpages.map(toSerializable),
    59  - packages: packages.map(toSerializable),
    60  - vulnerabilities,
    61  - };
    62  - },
    63  - })
    64  - .mutation('requestParseWebsite', {
    65  - input: z.string().url(),
    66  - async resolve({ input: url }) {
    67  - return toSerializable(await requestWebPageParse(url));
    68  - },
    69  - })
    70  - .formatError(({ shape, error }) => {
    71  - // TODO: proper reporting
    72  - return {
    73  - ...shape,
    74  - data: {
    75  - ...shape.data,
    76  - zodError:
    77  - error.code === 'BAD_REQUEST' && error.cause instanceof ZodError
    78  - ? error.cause.flatten()
    79  - : null,
    80  - },
    81  - };
    82  - });
    83  - 
    84  -// export type definition of API
    85  -export type AppRouter = typeof appRouter;
    86  - 
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/router.test.ts packages/public-api/src/website/router.test.ts
    1  -import { TRPC_ERROR_CODES_BY_KEY } from '@trpc/server/rpc';
    2 1  import {
    3 2   internalApi,
    4 3   PackageMetadata,
    skipped 7 lines
    12 11   useTransactionalTesting,
    13 12  } from '@gradejs-public/test-utils';
    14 13  import { getRepository } from 'typeorm';
    15  -import { createApp } from './app';
     14 +import { createApp } from '../app';
    16 15   
    17 16  useDatabaseConnection();
    18 17  useTransactionalTesting();
    19 18   
    20 19  const api = createSupertestApi(createApp);
    21 20   
    22  -describe('routes / heathCheck', () => {
    23  - it('should return valid response', async () => {
    24  - await api.get('/healthcheck').set('Origin', 'http://localhost:3000').send().expect(200);
    25  - });
    26  - 
    27  - it('should return not found error', async () => {
     21 +describe('POST /website', () => {
     22 + it('should validate body', async () => {
    28 23   const response = await api
    29  - .get('/any-invalid-route')
    30  - .set('Origin', 'http://localhost:3000')
    31  - .send()
    32  - .expect(404);
     24 + .post('/webpage')
     25 + .set('Origin', 'http://test')
     26 + .send({ url: 'invalid url' })
     27 + .expect(400);
    33 28   
    34 29   expect(response.body).toMatchObject({
    35 30   error: {
    36  - code: TRPC_ERROR_CODES_BY_KEY.NOT_FOUND,
     31 + code: 400,
     32 + message: 'Invalid url',
     33 + type: 'invalid_string',
     34 + param: 'url',
    37 35   },
    38 36   });
    39 37   });
    40  -});
    41 38   
    42  -describe('routes / website', () => {
    43 39   it('should initiate webpage parsing', async () => {
    44 40   const siteUrl = 'https://example.com/' + Math.random().toString();
    45 41   
    skipped 7 lines
    53 49   );
    54 50   
    55 51   const response = await api
    56  - .post('/requestParseWebsite')
    57  - .set('Origin', 'http://localhost:3000')
    58  - .send(JSON.stringify(siteUrl))
     52 + .post('/webpage')
     53 + .set('Origin', 'http://test')
     54 + .send({ url: siteUrl })
    59 55   .expect(200);
    60 56   const webpage = await getRepository(WebPage).findOne({ url: siteUrl });
    61 57   
    62 58   expect(initiateUrlProcessingInternalMock).toHaveBeenCalledTimes(1);
    63 59   expect(initiateUrlProcessingInternalMock).toHaveBeenCalledWith(siteUrl);
    64 60   expect(response.body).toMatchObject({
    65  - result: {
    66  - data: {
    67  - id: expect.anything(),
    68  - url: siteUrl,
    69  - hostname: 'example.com',
    70  - status: 'pending',
    71  - },
     61 + data: {
     62 + id: expect.anything(),
     63 + url: siteUrl,
     64 + hostname: 'example.com',
     65 + status: 'pending',
    72 66   },
    73 67   });
    74 68   
    skipped 1 lines
    76 70   url: siteUrl,
    77 71   hostname: 'example.com',
    78 72   status: 'pending',
     73 + });
     74 + });
     75 +});
     76 + 
     77 +describe('GET /website/:hostname', () => {
     78 + it('should validate params', async () => {
     79 + const response = await api
     80 + .get('/website/any:invalid')
     81 + .set('Origin', 'http://test')
     82 + .send()
     83 + .expect(400);
     84 + 
     85 + expect(response.body).toMatchObject({
     86 + error: {
     87 + code: 400,
     88 + message: 'Invalid',
     89 + type: 'invalid_string',
     90 + param: 'hostname',
     91 + },
    79 92   });
    80 93   });
    81 94   
    skipped 40 lines
    122 135   },
    123 136   });
    124 137   
    125  - const response = await api
    126  - .post('/syncWebsite')
    127  - .set('Origin', 'http://localhost:3000')
    128  - .send(JSON.stringify(hostname))
    129  - .expect(200);
     138 + const response = await api.get(`/website/${hostname}`).set('Origin', 'http://test').expect(200);
    130 139   
    131 140   expect(fetchUrlPackagesMock).toHaveBeenCalledTimes(0);
    132 141   expect(response.body).toMatchObject({
    133  - result: {
    134  - data: {
    135  - webpages: webpageInsert.generatedMaps,
    136  - packages: [
     142 + data: {
     143 + webpages: webpageInsert.generatedMaps,
     144 + packages: [
     145 + {
     146 + ...packageInsert.generatedMaps[0],
     147 + registryMetadata: packageMetadataInsert.generatedMaps[0],
     148 + },
     149 + ],
     150 + vulnerabilities: {
     151 + react: [
    137 152   {
    138  - ...packageInsert.generatedMaps[0],
    139  - registryMetadata: packageMetadataInsert.generatedMaps[0],
     153 + affectedPackageName: 'react',
     154 + affectedVersionRange: '>=17.0.0 <18.0.0',
     155 + osvId: 'GRJS-test-id',
     156 + detailsUrl: `https://github.com/advisories/GRJS-test-id`,
     157 + summary: 'Test summary',
     158 + severity: 'HIGH',
    140 159   },
    141 160   ],
    142  - vulnerabilities: {
    143  - react: [
    144  - {
    145  - affectedPackageName: 'react',
    146  - affectedVersionRange: '>=17.0.0 <18.0.0',
    147  - osvId: 'GRJS-test-id',
    148  - detailsUrl: `https://github.com/advisories/GRJS-test-id`,
    149  - summary: 'Test summary',
    150  - severity: 'HIGH',
    151  - },
    152  - ],
    153  - },
    154 161   },
    155 162   },
    156 163   });
    skipped 36 lines
    193 200   } as internalApi.Website)
    194 201   );
    195 202   
    196  - const response = await api
    197  - .post('/syncWebsite')
    198  - .set('Origin', 'http://localhost:3000')
    199  - .send(JSON.stringify(hostname))
    200  - .expect(200);
     203 + const response = await api.get(`/website/${hostname}`).set('Origin', 'http://test').expect(200);
    201 204   expect(fetchUrlPackagesMock).toHaveBeenCalledTimes(1);
    202 205   expect(fetchUrlPackagesMock).toHaveBeenCalledWith(siteUrl);
    203 206   
    204 207   expect(response.body).toMatchObject({
    205  - result: {
    206  - data: {
    207  - webpages: [
    208  - {
    209  - ...webpageInsert.generatedMaps.at(0),
    210  - status: WebPage.Status.Processed,
    211  - updatedAt: expect.anything(),
    212  - },
    213  - ],
    214  - packages: [
    215  - {
    216  - latestUrl: siteUrl,
    217  - hostname,
    218  - packageName: 'react',
    219  - possiblePackageVersions: ['17.0.2'],
    220  - packageVersionRange: '17.0.2',
    221  - packageMetadata: {
    222  - approximateByteSize: 1337,
    223  - },
     208 + data: {
     209 + webpages: [
     210 + {
     211 + ...webpageInsert.generatedMaps.at(0),
     212 + status: WebPage.Status.Processed,
     213 + updatedAt: expect.anything(),
     214 + },
     215 + ],
     216 + packages: [
     217 + {
     218 + latestUrl: siteUrl,
     219 + hostname,
     220 + packageName: 'react',
     221 + possiblePackageVersions: ['17.0.2'],
     222 + packageVersionRange: '17.0.2',
     223 + packageMetadata: {
     224 + approximateByteSize: 1337,
    224 225   },
    225  - {
    226  - latestUrl: siteUrl,
    227  - hostname,
    228  - packageName: 'object-assign',
    229  - possiblePackageVersions: ['4.1.0', '4.1.1'],
    230  - packageVersionRange: '4.1.0 - 4.1.1',
    231  - packageMetadata: {
    232  - approximateByteSize: 42,
    233  - },
     226 + },
     227 + {
     228 + latestUrl: siteUrl,
     229 + hostname,
     230 + packageName: 'object-assign',
     231 + possiblePackageVersions: ['4.1.0', '4.1.1'],
     232 + packageVersionRange: '4.1.0 - 4.1.1',
     233 + packageMetadata: {
     234 + approximateByteSize: 42,
    234 235   },
    235  - ],
    236  - vulnerabilities: {},
    237  - },
     236 + },
     237 + ],
     238 + vulnerabilities: {},
    238 239   },
    239 240   });
    240 241   });
    skipped 2 lines
  • ■ ■ ■ ■ ■ ■
    packages/public-api/src/website/router.ts
     1 +import { z } from 'zod';
     2 +import { NotFoundError } from '../middleware/response';
     3 +import { StrictRouter } from '../middleware/strictRouter';
     4 +import { getRequestBody, getRequestParams } from '../middleware/validation';
     5 +import { getAffectingVulnerabilities } from '../vulnerabilities/vulnerabilities';
     6 +import {
     7 + getPackagesByHostname,
     8 + getWebPagesByHostname,
     9 + requestWebPageParse,
     10 + syncWebPage,
     11 +} from './service';
     12 + 
     13 +const hostnameRe =
     14 + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)+([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$/;
     15 + 
     16 +const strictRouter = new StrictRouter();
     17 + 
     18 +strictRouter.get('/website/:hostname', async (req) => {
     19 + const { hostname } = getRequestParams(req, { hostname: z.string().regex(hostnameRe) });
     20 + 
     21 + const webpages = await getWebPagesByHostname(hostname);
     22 + 
     23 + if (webpages.length === 0) {
     24 + throw new NotFoundError();
     25 + }
     26 + 
     27 + // Sync webpages if status is not processed
     28 + await Promise.all(webpages.map((webpage) => syncWebPage(webpage)));
     29 + 
     30 + const packages = await getPackagesByHostname(hostname);
     31 + const vulnerabilities = await getAffectingVulnerabilities(packages);
     32 + 
     33 + return { webpages, packages, vulnerabilities };
     34 +});
     35 + 
     36 +strictRouter.post('/webpage', async (req) => {
     37 + const { url } = getRequestBody(req, { url: z.string().url() });
     38 + 
     39 + const webpage = await requestWebPageParse(url);
     40 + 
     41 + return webpage;
     42 +});
     43 + 
     44 +export default strictRouter.router;
     45 + 
  • ■ ■ ■ ■ ■ ■
    packages/shared/src/apiContract.ts
     1 +import { PackageVulnerabilityData } from './database/entities/packageVulnerability';
     2 +import { WebPage } from './database/entities/webPage';
     3 +import { WebPagePackage } from './database/entities/webPagePackage';
     4 + 
     5 +/**
     6 + * We use single api typings for both frontend and backend packages
     7 + */
     8 +export type RouteMap = {
     9 + '/': {
     10 + GET: {
     11 + response: string;
     12 + };
     13 + };
     14 + '/website/:hostname': {
     15 + GET: {
     16 + params: {
     17 + hostname: string;
     18 + };
     19 + response: {
     20 + webpages: WebPage[];
     21 + packages: WebPagePackage[];
     22 + vulnerabilities: Record<string, PackageVulnerabilityData[]>;
     23 + };
     24 + };
     25 + };
     26 + '/webpage': {
     27 + POST: {
     28 + body: {
     29 + url: string;
     30 + };
     31 + response: WebPage;
     32 + };
     33 + };
     34 +};
     35 + 
     36 +export type Routes = keyof RouteMap;
     37 +export type GetRoutes = PickRoutesByMethod<'GET'>;
     38 +export type PostRoutes = PickRoutesByMethod<'POST'>;
     39 + 
     40 +export type RequestOptions = {
     41 + params?: Record<string, unknown>;
     42 + query?: Record<string, unknown>;
     43 + body?: Record<string, unknown>;
     44 +};
     45 + 
     46 +/**
     47 + * Server entities
     48 + */
     49 +export { WebPage, WebPagePackage, PackageVulnerabilityData };
     50 + 
     51 +/** Generics */
     52 + 
     53 +export type Response<TData = undefined> = { data: TData } | { error: Error };
     54 + 
     55 +export type Error = {
     56 + code: number;
     57 + message?: string;
     58 + param?: string;
     59 + type?: string;
     60 +};
     61 + 
     62 +type PickRoutesByMethod<Method> = {
     63 + [Route in Routes]: keyof RouteMap[Route] extends Method ? Route : never;
     64 +}[Routes];
     65 + 
  • ■ ■ ■ ■ ■ ■
    packages/shared/src/index.ts
    skipped 11 lines
    12 12   
    13 13  export * from './worker/types';
    14 14   
     15 +export * as API from './apiContract';
     16 + 
  • ■ ■ ■ ■ ■
    packages/web/package.json
    skipped 50 lines
    51 51   },
    52 52   "dependencies": {
    53 53   "@reduxjs/toolkit": "^1.8.3",
    54  - "@trpc/client": "^9.27.0",
    55 54   "@types/lodash.memoize": "^4.1.7",
    56 55   "clsx": "^1.1.1",
    57 56   "lodash.memoize": "^4.1.2",
    skipped 11 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/layouts/Website/Website.tsx
    skipped 7 lines
    8 8  import Filters, { FiltersState } from '../Filters/Filters';
    9 9  import TagBadge from '../../ui/TagBadge/TagBadge';
    10 10  import { trackCustomEvent } from '../../../services/analytics';
    11  -import { Api } from '../../../services/apiClient';
     11 +import { WebPagePackage, WebPage, PackageVulnerabilityData } from '../../../services/apiClient';
    12 12   
    13 13  // TODO: Add plashechka
    14 14  export type Props = {
    skipped 5 lines
    20 20   // }>;
    21 21   isLoading: boolean;
    22 22   isPending: boolean;
    23  - packages: Api.WebPagePackage[];
    24  - vulnerabilities: Record<string, Api.Vulnerability[]>;
    25  - webpages: Api.WebPage[];
     23 + packages: WebPagePackage[];
     24 + vulnerabilities: Record<string, PackageVulnerabilityData[]>;
     25 + webpages: WebPage[];
    26 26   onFiltersApply: SubmitHandler<FiltersState>;
    27 27  };
    28 28   
    skipped 117 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Package/Package.tsx
    skipped 7 lines
    8 8  import Vulnerability from '../Vulnerability/Vulnerability';
    9 9  import TagBadge from '../TagBadge/TagBadge';
    10 10  import { trackCustomEvent } from '../../../services/analytics';
    11  -import { Api } from '../../../services/apiClient';
     11 +import { WebPagePackage, PackageVulnerabilityData } from '../../../services/apiClient';
    12 12   
    13 13  export type Props = {
    14 14   className?: string;
    15 15   variant?: 'grid' | 'lines';
    16  - pkg: Api.WebPagePackage;
    17  - vulnerabilities: Api.Vulnerability[];
     16 + pkg: WebPagePackage;
     17 + vulnerabilities: PackageVulnerabilityData[];
    18 18  };
    19 19   
    20 20  export default function Package({ className, variant = 'grid', pkg, vulnerabilities }: Props) {
    skipped 106 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/components/ui/Vulnerability/Vulnerability.tsx
    skipped 2 lines
    3 3  import TagBadge from '../TagBadge/TagBadge';
    4 4  import styles from './Vulnerability.module.scss';
    5 5  import { External } from '../../icons';
    6  -import { Api } from '../../../services/apiClient';
     6 +import { PackageVulnerabilityData } from '../../../services/apiClient';
    7 7   
    8 8  export type Props = {
    9  - vulnerability: Api.Vulnerability;
     9 + vulnerability: PackageVulnerabilityData;
    10 10  };
    11 11   
    12 12  export default function Vulnerability({ vulnerability }: Props) {
    skipped 55 lines
  • ■ ■ ■ ■ ■
    packages/web/src/services/apiClient.ts
    1  -import { createTRPCClient } from '@trpc/client';
    2  -import type { inferProcedureOutput } from '@trpc/server';
    3  -import type { AppRouter, Api } from '../../../public-api/src/router';
     1 +import type { API } from '../../../shared/src/index';
    4 2   
    5  -// Helper types
    6  -export type TQuery = keyof AppRouter['_def']['queries'];
    7  -export type TMutation = keyof AppRouter['_def']['mutations'];
    8  -export type InferMutationOutput<TRouteKey extends TMutation> = inferProcedureOutput<
    9  - AppRouter['_def']['mutations'][TRouteKey]
    10  ->;
    11  -export type InferQueryOutput<TRouteKey extends TQuery> = inferProcedureOutput<
    12  - AppRouter['_def']['queries'][TRouteKey]
    13  ->;
     3 +/**
     4 + * Server Entities
     5 + */
     6 +export type WebPage = JsonSerialized<API.WebPage>;
     7 +export type WebPagePackage = JsonSerialized<API.WebPagePackage>;
     8 +export type PackageVulnerabilityData = JsonSerialized<API.PackageVulnerabilityData>;
    14 9   
    15  -if (!process.env.API_ORIGIN) {
    16  - throw new Error('API_ORIGIN must be defined');
     10 +/**
     11 + * Client
     12 + */
     13 +export const apiClient = {
     14 + get<Route extends API.GetRoutes, Method extends keyof API.RouteMap[Route]>(
     15 + route: Route,
     16 + options: EndpointOptions<Route, Method>
     17 + ) {
     18 + return fetchEndpoint('GET', route, options);
     19 + },
     20 + post<Route extends API.PostRoutes, Method extends keyof API.RouteMap[Route]>(
     21 + route: Route,
     22 + options: EndpointOptions<Route, Method>
     23 + ) {
     24 + return fetchEndpoint('POST', route, options);
     25 + },
     26 +};
     27 + 
     28 +async function fetchEndpoint<Route extends API.Routes, Method extends keyof API.RouteMap[Route]>(
     29 + method: Method,
     30 + route: Route,
     31 + options: API.RequestOptions
     32 +) {
     33 + const url = new URL(route, process.env.API_ORIGIN);
     34 + const requestInit: RequestInit = {
     35 + method: method as string,
     36 + headers: { 'Content-Type': 'application/json' },
     37 + };
     38 + 
     39 + if (options.params) {
     40 + for (const [key, value] of Object.entries(options.params)) {
     41 + url.pathname = url.pathname.replace(`:${key}`, String(value));
     42 + }
     43 + }
     44 + if (options.query) {
     45 + for (const [key, value] of Object.entries(options.query)) {
     46 + url.searchParams.set(key, String(value));
     47 + }
     48 + }
     49 + 
     50 + if (options.body) {
     51 + requestInit.body = JSON.stringify(options.body);
     52 + }
     53 + 
     54 + const response = await fetch(url.toString(), requestInit);
     55 + const body = await response.json();
     56 + 
     57 + if (response.status === 200) {
     58 + return body.data as EndpointResponse<Route, Method>;
     59 + } else {
     60 + throw body.error;
     61 + }
    17 62  }
    18 63   
    19  -export const client = createTRPCClient<AppRouter>({
    20  - url: process.env.API_ORIGIN,
    21  -});
     64 +export type EndpointResponse<Route extends API.Routes, Method extends keyof API.RouteMap[Route]> =
     65 + Method extends keyof API.RouteMap[Route]
     66 + ? 'response' extends keyof API.RouteMap[Route][Method]
     67 + ? JsonSerialized<API.RouteMap[Route][Method]['response']>
     68 + : never
     69 + : never;
    22 70   
    23  -export type HealthcheckOutput = InferQueryOutput<'healthcheck'>;
    24  -export type SyncWebsiteOutput = InferMutationOutput<'syncWebsite'>;
    25  -export type RequestParseWebsiteOutput = InferMutationOutput<'requestParseWebsite'>;
    26  -export type { Api };
    27  -export type ApiClient = typeof client;
     71 +// Some data types in entities may be serialized within JSON
     72 +type JsonSerialized<T> = {
     73 + [Key in keyof T as T[Key] extends Function ? never : Key]: T[Key] extends Record<string, unknown>
     74 + ? JsonSerialized<T[Key]>
     75 + : T[Key] extends (infer A)[]
     76 + ? JsonSerialized<A>[]
     77 + : T[Key] extends Date
     78 + ? string
     79 + : T[Key] extends Date | undefined
     80 + ? string | undefined
     81 + : T[Key];
     82 +};
     83 + 
     84 +type EndpointOptions<Route extends API.Routes, Method extends keyof API.RouteMap[Route]> = Omit<
     85 + API.RouteMap[Route][Method],
     86 + 'response'
     87 +>;
    28 88   
  • ■ ■ ■ ■ ■ ■
    packages/web/src/store/selectors/websiteResults.ts
    skipped 4 lines
    5 5  import { RootState } from '../';
    6 6  import { FiltersState } from '../../components/layouts/Filters/Filters';
    7 7  import { SeverityWeightMap } from '../../components/ui/Vulnerability/Vulnerability';
    8  -import type { Api } from '../../services/apiClient';
     8 +import { PackageVulnerabilityData, WebPagePackage } from '../../services/apiClient';
    9 9   
    10 10  const getFlags = (state: RootState) => ({
    11 11   isLoading: state.webpageResults.isLoading,
    skipped 7 lines
    19 19  const getFilter = (state: RootState) => state.webpageResults.filters.filter;
    20 20  const getPackageNameFilter = (state: RootState) => state.webpageResults.filters.filterPackageName;
    21 21   
    22  -const compareByPopularity = (left: Api.WebPagePackage, right: Api.WebPagePackage) =>
     22 +const compareByPopularity = (left: WebPagePackage, right: WebPagePackage) =>
    23 23   (right.registryMetadata?.monthlyDownloads ?? 0) - (left.registryMetadata?.monthlyDownloads ?? 0);
    24 24   
    25 25  const pickHighestSeverity = memoize(
    26  - (packageName: string, vulnerabilities: Record<string, Api.Vulnerability[]>) =>
     26 + (packageName: string, vulnerabilities: Record<string, PackageVulnerabilityData[]>) =>
    27 27   (vulnerabilities[packageName] ?? [])
    28 28   .map((it) => it.severity)
    29 29   .filter((it): it is GithubAdvisorySeverity => !!it)
    skipped 6 lines
    36 36  const sortingModes: Record<
    37 37   FiltersState['sort'],
    38 38   (
    39  - packages: Api.WebPagePackage[],
    40  - vulnerabilities: Record<string, Api.Vulnerability[]>
    41  - ) => Api.WebPagePackage[]
     39 + packages: WebPagePackage[],
     40 + vulnerabilities: Record<string, PackageVulnerabilityData[]>
     41 + ) => WebPagePackage[]
    42 42  > = {
    43 43   // TODO
    44 44   confidenceScore: (packages) => packages,
    skipped 24 lines
    69 69  const filterModes: Record<
    70 70   FiltersState['filter'],
    71 71   (
    72  - packages: Api.WebPagePackage[],
    73  - vulnerabilities: Record<string, Api.Vulnerability[]>,
     72 + packages: WebPagePackage[],
     73 + vulnerabilities: Record<string, PackageVulnerabilityData[]>,
    74 74   packageName?: string
    75  - ) => Api.WebPagePackage[]
     75 + ) => WebPagePackage[]
    76 76  > = {
    77 77   name: (packages, vulnerabilities, packageName) => {
    78 78   if (!packageName) {
    skipped 40 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/store/slices/home.ts
    1 1  import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
    2  -import { client } from '../../services/apiClient';
     2 +import { apiClient } from '../../services/apiClient';
    3 3  import { RootState } from '../';
    4 4   
    5 5  const parseWebsite = createAsyncThunk('home/submitWebsite', async (url: string) => {
    6  - await client.mutation('requestParseWebsite', url);
     6 + await apiClient.post('/webpage', { body: { url } });
    7 7  });
    8 8   
    9 9  const home = createSlice({
    skipped 34 lines
  • ■ ■ ■ ■ ■ ■
    packages/web/src/store/slices/websiteResults.ts
    1 1  import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit';
    2 2  import { DefaultFiltersAndSorters, FiltersState } from '../../components/layouts/Filters/Filters';
    3  -import { client, SyncWebsiteOutput } from '../../services/apiClient';
     3 +import { apiClient, EndpointResponse } from '../../services/apiClient';
    4 4  import { trackCustomEvent } from '../../services/analytics';
    5 5   
    6  -const defaultDetectionResult: SyncWebsiteOutput = {
     6 +const defaultDetectionResult: EndpointResponse<'/website/:hostname', 'GET'> = {
    7 7   packages: [],
    8 8   vulnerabilities: {},
    9 9   webpages: [],
    skipped 16 lines
    26 26   
    27 27  const getWebsite = createAsyncThunk('websiteResults/getWebsite', async (hostname: string) => {
    28 28   const loadStartTime = Date.now();
    29  - let results = await client.mutation('syncWebsite', hostname);
     29 + let results = await apiClient.get('/website/:hostname', { params: { hostname } });
    30 30   while (hasPendingPages(results)) {
    31 31   await sleep(5000);
    32  - results = await client.mutation('syncWebsite', hostname);
     32 + results = await apiClient.get('/website/:hostname', { params: { hostname } });
    33 33   }
    34 34   // TODO: move to tracking middleware?
    35 35   trackCustomEvent('HostnamePage', 'WebsiteLoaded', {
    skipped 38 lines
Please wait...
Page is in error, reload to recover