Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 120 additions & 83 deletions app/api/server/api.d.ts
Original file line number Diff line number Diff line change
@@ -1,110 +1,147 @@
import { Endpoints } from '../../../definition/rest';
import { Awaited } from '../../../definition/utils';
import { IUser } from '../../../definition/IUser';
import type { JoinPathPattern, Method, MethodOf, OperationParams, OperationResult, PathPattern, UrlParams } from '../../../definition/rest';
import type { IUser } from '../../../definition/IUser';


export type ChangeTypeOfKeys<
T extends object,
Keys extends keyof T,
NewType
> = {
[key in keyof T]: key extends Keys ? NewType : T[key]
}

type This = {
getPaginationItems(): ({
offset: number;
count: number;
});
parseJsonQuery(): ({
sort: Record<string, unknown>;
fields: Record<string, unknown>;
query: Record<string, unknown>;
});
readonly urlParams: Record<string, unknown>;
getUserFromParams(): IUser;
}

type ThisLoggedIn = {
readonly user: IUser;
readonly userId: string;
}

type ThisLoggedOut = {
readonly user: null;
readonly userId: null;
}

type EndpointWithExtraOptions<FN extends (this: any, ...args: any) => any, A> = WrappedFunction<FN> | ({ action: WrappedFunction<FN> } & (A extends true ? { twoFactorRequired: boolean } : {}));

export type Methods<T, A = false> = {
[K in keyof T as `${Lowercase<string & K>}`]: T[K] extends (...args: any) => any ? EndpointWithExtraOptions<(this: This & (A extends true ? ThisLoggedIn : ThisLoggedOut) & Params<K, Parameters<T[K]>[0]>) => ReturnType<T[K]>, A> : never;
type SuccessResult<T> = {
statusCode: 200;
body:
T extends object
? { success: true } & T
: T;
};

type Params<K, P> = K extends 'GET' ? { readonly queryParams: Partial<P> } : K extends 'POST' ? { readonly bodyParams: Partial<P> } : never;

type SuccessResult<T = undefined> = {
statusCode: 200;
success: true;
} & T extends (undefined) ? {} : { body: T }
type FailureResult<T, TStack = undefined, TErrorType = undefined, TErrorDetails = undefined> = {
statusCode: 400;
body:
T extends object
? { success: false } & T
: ({
success: false;
error: T;
stack: TStack;
errorType: TErrorType;
details: TErrorDetails;
}) & (
undefined extends TErrorType
? {}
: { errorType: TErrorType }
) & (
undefined extends TErrorDetails
? {}
: { details: TErrorDetails extends string ? unknown : TErrorDetails }
);
};

type UnauthorizedResult = {
type UnauthorizedResult<T> = {
statusCode: 403;
body: {
success: false;
error: string;
error: T | 'unauthorized';
};
}

type FailureResult<T = undefined, ET = undefined, ST = undefined, E = undefined> = {
statusCode: 400;
} & FailureBody<T, ET, ST, { success: false }>;

type FailureBody<T, ET, ST, E> = Exclude<T, string> extends object ? { body: T & E } : {
body: E & { error: string } & E extends Error ? { details: string } : {} & ST extends undefined ? {} : { stack: string } & ET extends undefined ? {} : { errorType: string };
}

type Errors = FailureResult | FailureResult<object> | FailureResult<string, string, string, { error: string }> | UnauthorizedResult;

type WrappedFunction<T extends (this: any, ...args: any) => any> = (this: ThisParameterType<T>, ...args: Parameters<T>) => ReturnTypes<T>;

type ReturnTypes<T extends (this: any, ...args: any) => any> = PromisedOrNot<SuccessResult<ReturnType<T>> | PromisedOrNot<Errors>>;

type PromisedOrNot<T> = Promise<T> | T;

type Options = {
permissionsRequired?: string[];
twoFactorOptions?: unknown;
twoFactorRequired?: boolean;
authRequired?: boolean;
}

export type RestEndpoints<P extends keyof Endpoints, A = false> = Methods<Endpoints[P], A>;

type ToLowerCaseKeys<T> = {
[K in keyof T as `${Lowercase<string & K>}`]: T[K];
type ActionThis<TMethod extends Method, TPathPattern extends PathPattern, TOptions> = {
urlParams: UrlParams<TPathPattern>;
// TODO make it unsafe
readonly queryParams: TMethod extends 'GET' ? Partial<OperationParams<TMethod, TPathPattern>> : Record<string, string>;
// TODO make it unsafe
readonly bodyParams: TMethod extends 'GET' ? Record<string, unknown> : Partial<OperationParams<TMethod, TPathPattern>>;
requestParams(): OperationParams<TMethod, TPathPattern>;
getPaginationItems(): {
readonly offset: number;
readonly count: number;
};
parseJsonQuery(): {
sort: Record<string, unknown>;
fields: Record<string, unknown>;
query: Record<string, unknown>;
};
getUserFromParams(): IUser;
} & (
TOptions extends { authRequired: true }
? {
readonly user: IUser;
readonly userId: string;
}
: {
readonly user: null;
readonly userId: null;
}
);

export type ResultFor<
TMethod extends Method,
TPathPattern extends PathPattern
> = SuccessResult<OperationResult<TMethod, TPathPattern>> | FailureResult<unknown, unknown, unknown, unknown> | UnauthorizedResult<unknown>;

type Action<TMethod extends Method, TPathPattern extends PathPattern, TOptions> =
((this: ActionThis<TMethod, TPathPattern, TOptions>) => Promise<ResultFor<TMethod, TPathPattern>>)
| ((this: ActionThis<TMethod, TPathPattern, TOptions>) => ResultFor<TMethod, TPathPattern>);

type Operation<TMethod extends Method, TPathPattern extends PathPattern, TEndpointOptions> = Action<TMethod, TPathPattern, TEndpointOptions> | {
action: Action<TMethod, TPathPattern, TEndpointOptions>;
} & ({ twoFactorRequired: boolean });

type Operations<TPathPattern extends PathPattern, TOptions extends Options = {}> = {
[M in MethodOf<TPathPattern> as Lowercase<M>]: Operation<Uppercase<M>, TPathPattern, TOptions>;
};
type ToResultType<T> = {
[K in keyof T]: T[K] extends (...args: any) => any ? Awaited<ReturnTypes<T[K]>> : never;
}
export type ResultTypeEndpoints<P extends keyof Endpoints, A = false> = ToResultType<ToLowerCaseKeys<Endpoints[P]>>;

declare class APIClass {
addRoute<P extends keyof Endpoints>(route: P, endpoints: RestEndpoints<P>): void;
declare class APIClass<TBasePath extends string = '/'> {
addRoute<
TSubPathPattern extends string
>(subpath: TSubPathPattern, operations: Operations<JoinPathPattern<TBasePath, TSubPathPattern>>): void;

addRoute<
TSubPathPattern extends string,
TPathPattern extends JoinPathPattern<TBasePath, TSubPathPattern>
>(subpaths: TSubPathPattern[], operations: Operations<TPathPattern>): void;

addRoute<
TSubPathPattern extends string,
TOptions extends Options
>(
subpath: TSubPathPattern,
options: TOptions,
operations: Operations<JoinPathPattern<TBasePath, TSubPathPattern>, TOptions>
): void;

addRoute<
TSubPathPattern extends string,
TPathPattern extends JoinPathPattern<TBasePath, TSubPathPattern>,
TOptions extends Options
>(
subpaths: TSubPathPattern[],
options: TOptions,
operations: Operations<TPathPattern, TOptions>
): void;

addRoute<P extends keyof Endpoints, O extends Options>(route: P, options: O, endpoints: RestEndpoints<P, O['authRequired'] extends true ? true: false>): void;
success<T>(result: T): SuccessResult<T>;

unauthorized(msg?: string): UnauthorizedResult;
success(): SuccessResult<void>;

failure<ET = string | undefined, ST = string | undefined, E = Error | undefined>(result: string, errorType?: ET, stack?: ST, error?: E): FailureResult<string, ET, ST, E>;
failure<
T,
TErrorType extends string,
TStack extends string,
TErrorDetails
>(
result: T,
errorType?: TErrorType,
stack?: TStack,
error?: { details: TErrorDetails }
): FailureResult<T, TErrorType, TStack, TErrorDetails>;

failure(result: object): FailureResult<object>;
failure<T>(result: T): FailureResult<T>;

failure(): FailureResult;
failure(): FailureResult<void>;

success(): SuccessResult<void>;

success<T>(result: T): SuccessResult<T>;
unauthorized<T>(msg?: T): UnauthorizedResult<T>;

defaultFieldsToExclude: {
joinCode: 0;
Expand All @@ -115,6 +152,6 @@ declare class APIClass {
}

export declare const API: {
v1: APIClass;
v1: APIClass<'/v1'>;
default: APIClass;
};
39 changes: 6 additions & 33 deletions app/api/server/v1/banners.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { Meteor } from 'meteor/meteor';
import { Match, check } from 'meteor/check';

import { API } from '../api';
Expand Down Expand Up @@ -54,20 +53,13 @@ import { BannerPlatform } from '../../../../definition/IBanner';
API.v1.addRoute('banners.getNew', { authRequired: true }, { // deprecated
async get() {
check(this.queryParams, Match.ObjectIncluding({
platform: String,
platform: Match.OneOf(...Object.values(BannerPlatform)),
bid: Match.Maybe(String),
}));

const { platform, bid: bannerId } = this.queryParams;
if (!platform) {
throw new Meteor.Error('error-missing-param', 'The required "platform" param is missing.');
}

if (!Object.values(BannerPlatform).includes(platform)) {
throw new Meteor.Error('error-unknown-platform', 'Platform is unknown.');
}

const banners = await Banner.getBannersForUser(this.userId, platform, bannerId);
const banners = await Banner.getBannersForUser(this.userId, platform, bannerId ?? undefined);

return API.v1.success({ banners });
},
Expand Down Expand Up @@ -122,23 +114,15 @@ API.v1.addRoute('banners.getNew', { authRequired: true }, { // deprecated
API.v1.addRoute('banners/:id', { authRequired: true }, { // TODO: move to users/:id/banners
async get() {
check(this.urlParams, Match.ObjectIncluding({
id: String,
id: Match.Where((id: unknown): id is string => typeof id === 'string' && Boolean(id.trim())),
}));

check(this.queryParams, Match.ObjectIncluding({
platform: String,
platform: Match.OneOf(...Object.values(BannerPlatform)),
}));

const { platform } = this.queryParams;

if (!platform) {
throw new Meteor.Error('error-missing-param', 'The required "platform" param is missing.');
}

const { id } = this.urlParams;
if (!id) {
throw new Meteor.Error('error-missing-param', 'The required "id" param is missing.');
}

const banners = await Banner.getBannersForUser(this.userId, platform, id);

Expand Down Expand Up @@ -186,17 +170,10 @@ API.v1.addRoute('banners/:id', { authRequired: true }, { // TODO: move to users/
API.v1.addRoute('banners', { authRequired: true }, {
async get() {
check(this.queryParams, Match.ObjectIncluding({
platform: String,
platform: Match.OneOf(...Object.values(BannerPlatform)),
}));

const { platform } = this.queryParams;
if (!platform) {
throw new Meteor.Error('error-missing-param', 'The required "platform" param is missing.');
}

if (!Object.values(BannerPlatform).includes(platform)) {
throw new Meteor.Error('error-unknown-platform', 'Platform is unknown.');
}

const banners = await Banner.getBannersForUser(this.userId, platform);

Expand Down Expand Up @@ -240,15 +217,11 @@ API.v1.addRoute('banners', { authRequired: true }, {
API.v1.addRoute('banners.dismiss', { authRequired: true }, {
async post() {
check(this.bodyParams, Match.ObjectIncluding({
bannerId: String,
bannerId: Match.Where((id: unknown): id is string => typeof id === 'string' && Boolean(id.trim())),
}));

const { bannerId } = this.bodyParams;

if (!bannerId || !bannerId.trim()) {
throw new Meteor.Error('error-missing-param', 'The required "bannerId" param is missing.');
}

await Banner.dismiss(this.userId, bannerId);
return API.v1.success();
},
Expand Down
Loading