Skip to content

Commit

Permalink
move to openapi generation
Browse files Browse the repository at this point in the history
  • Loading branch information
ChristopherJMiller committed Jan 5, 2024
1 parent a945630 commit 70aa277
Show file tree
Hide file tree
Showing 27 changed files with 747 additions and 118 deletions.
3 changes: 2 additions & 1 deletion api/nest-cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"plugins": ["@nestjs/swagger"]
}
}
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.1.17",
"@nestjs/typeorm": "^10.0.1",
"@types/passport-jwt": "^3.0.13",
"bcrypt": "^5.1.1",
Expand Down
37 changes: 33 additions & 4 deletions api/src/groups/group.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,25 @@ import {
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { GroupsService } from './group.service';
import { Group } from './group.entity';
import { Group, NewGroup } from './group.entity';
import { AdminRoute } from 'src/admin/admin.decorator';
import { User } from 'src/users/user.entity';
import { UsersService } from 'src/users/users.service';
import { AdminGuard } from 'src/admin/admin.guard';
import { GetByIdParameter } from 'src/utils/types';
import {
ApiBearerAuth,
ApiBody,
ApiParam,
ApiParamOptions,
} from '@nestjs/swagger';

const groupIdParam: ApiParamOptions = {
name: 'id',
required: true,
type: 'string',
description: 'Group Id',
};

@Controller()
export class GroupController {
Expand All @@ -24,7 +38,9 @@ export class GroupController {

@UseGuards(AuthGuard('jwt'))
@Get('groups/:id')
getById(@Param() params: any): Promise<Group> {
@ApiParam(groupIdParam)
@ApiBearerAuth()
getById(@Param() params: GetByIdParameter): Promise<Group> {
return this.groupService.findOneBy({ id: params.id });
}

Expand All @@ -36,21 +52,34 @@ export class GroupController {
@Post('groups')
@AdminRoute()
@UseGuards(AuthGuard('jwt'), AdminGuard)
@ApiBearerAuth()
@ApiBody({
required: true,
type: NewGroup,
description: 'New Group, can omit id',
})
createGroup(@Body() newGroup: Omit<Group, 'id'>): Promise<Group> {
return this.groupService.create(newGroup);
}

@UseGuards(AuthGuard('jwt'))
@Get('groups/:id/members')
groupMembers(@Param() params: any): Promise<User[]> {
@ApiBearerAuth()
@ApiParam(groupIdParam)
groupMembers(@Param() params: GetByIdParameter): Promise<User[]> {
return this.groupService
.findOneBy({ id: params.id })
.then((group) => group.users);
}

@UseGuards(AuthGuard('jwt'))
@Post('groups/:id/join')
async join(@Request() req, @Param() params: any): Promise<Group> {
@ApiBearerAuth()
@ApiParam(groupIdParam)
async join(
@Request() req,
@Param() params: GetByIdParameter,
): Promise<Group> {
const id = req.user.sub;

if (!id) {
Expand Down
4 changes: 4 additions & 0 deletions api/src/groups/group.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { IsNotEmpty } from 'class-validator';
import { User } from 'src/users/user.entity';
import { Entity, Column, PrimaryGeneratedColumn, ManyToMany } from 'typeorm';

export class NewGroup {
name: string;
}

@Entity()
export class Group {
@PrimaryGeneratedColumn('uuid')
Expand Down
11 changes: 10 additions & 1 deletion api/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
Expand All @@ -11,9 +12,17 @@ async function bootstrap() {
app.useGlobalPipes(new ValidationPipe());
app.enableCors({
origin: '*',
preflightContinue: false
preflightContinue: false,
});

const config = new DocumentBuilder()
.setTitle('Realliance Community API')
.setDescription('An API to interact with Realliance groups and members')
.setVersion('1.0')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);

await app.listen(3000);
}

Expand Down
61 changes: 58 additions & 3 deletions api/src/users/user.controller.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,75 @@
import { Controller, Request, Get, UseGuards, Param } from '@nestjs/common';
import {
Controller,
Request,
Get,
UseGuards,
Param,
Patch,
Body,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { UsersService } from './users.service';
import { User } from './user.entity';
import { UpdateUser, User } from './user.entity';
import {
ApiBearerAuth,
ApiBody,
ApiParam,
ApiParamOptions,
} from '@nestjs/swagger';

const usernameParam: ApiParamOptions = {
name: 'username',
required: true,
type: 'string',
description: 'Username',
};

interface GetByUsernameParam {
username: string;
}

@Controller()
export class UserController {
constructor(private userService: UsersService) {}

@Get('profile')
@UseGuards(AuthGuard('jwt'))
@ApiBearerAuth()
getProfile(@Request() req): Promise<User> {
return this.userService.findOneByJwt(req.user);
}

@Get('user/:username')
getOneUser(@Param() params: any): Promise<User> {
@ApiParam(usernameParam)
getOneUser(@Param() params: GetByUsernameParam): Promise<User> {
return this.userService.findOneBy({ username: params.username });
}

@Patch('user/:username')
@UseGuards(AuthGuard('jwt'))
@ApiBody({
required: true,
type: UpdateUser,
description: 'Updated user parameters',
})
@ApiParam(usernameParam)
@ApiBearerAuth()
updateUser(
@Request() req,
@Param() params: GetByUsernameParam,
@Body() updatedUser: UpdateUser,
): Promise<void> {
const fromJwt = User.fromJwt(req.user);
const isSameUser = fromJwt.username === params.username;
const isAdmin = fromJwt.admin;

if (isAdmin || isSameUser) {
return this.userService.updateUser(
{ username: params.username },
updatedUser,
);
}

throw new Error('Not allowed to update this user');
}
}
4 changes: 4 additions & 0 deletions api/src/users/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { Entity, Column, PrimaryColumn, JoinTable, ManyToMany } from 'typeorm';
import { ReallianceIdJwt } from './jwt';
import { Group } from '../groups/group.entity';

export class UpdateUser {
description?: string;
}

@Entity()
export class User {
@PrimaryColumn()
Expand Down
9 changes: 3 additions & 6 deletions api/src/users/users.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,17 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { UsersService } from './users.service';
import { UserController } from './user.controller';
import { JwtStrategy } from './jwt.strategy';
import { PassportModule } from '@nestjs/passport';
import { UserController } from './user.controller';

@Module({
// forFeature is what triggers the entity to be loaded into the database management system as a persistant object type
imports: [
TypeOrmModule.forFeature([User]),
PassportModule.register({ defaultStrategy: 'jwt' })
],
providers: [
UsersService,
JwtStrategy,
PassportModule.register({ defaultStrategy: 'jwt' }),
],
providers: [UsersService, JwtStrategy],
controllers: [UserController],
exports: [UsersService],
})
Expand Down
11 changes: 7 additions & 4 deletions api/src/users/users.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FindOptionsWhere, Repository } from 'typeorm';
import { User } from './user.entity';
import { ReallianceIdJwt } from './jwt';

Expand All @@ -11,7 +11,7 @@ export class UsersService {
private usersRepository: Repository<User>,
) {}

async create(user: User): Promise<User> {
async create(user: User): Promise<User> {
return this.usersRepository.save(user);
}

Expand All @@ -33,7 +33,10 @@ export class UsersService {
await this.usersRepository.delete(id);
}

async updateUser(id: string, newUser: Partial<User>): Promise<void> {
async updateUser(
id: string | FindOptionsWhere<User>,
newUser: Partial<User>,
): Promise<void> {
await this.usersRepository.update(id, newUser);
}
}
}
3 changes: 3 additions & 0 deletions api/src/utils/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export interface GetByIdParameter {
id: string;
}
5 changes: 4 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"dev": "vite --open --port 8080",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
"preview": "vite preview",
"genSpec": "openapi-typescript http://localhost:3000/api-json -o ./src/util/v1.d.ts"
},
"dependencies": {
"@types/js-cookie": "^3.0.6",
Expand All @@ -18,6 +19,7 @@
"js-cookie": "^3.0.5",
"match-sorter": "^6.3.1",
"oauth4webapi": "^2.4.0",
"openapi-fetch": "^0.8.2",
"postcss": "^8.4.32",
"react": "^18.2.0",
"react-cookie": "^6.1.1",
Expand All @@ -36,6 +38,7 @@
"eslint": "^8.53.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"openapi-typescript": "^6.7.3",
"typescript": "^5.2.2",
"vite": "^5.0.0"
}
Expand Down
37 changes: 37 additions & 0 deletions frontend/src/components/TextInputWithLabel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Label, TextInput } from 'flowbite-react';

interface TextInputWithLabelProps {
id: string;
name: string;
placeholder?: string;
required?: boolean;
disabled?: boolean;
value?: string;
helperText?: React.ReactNode;
}

export function TextInputWithLabel({
id,
name,
placeholder,
required,
disabled,
value,
helperText,
}: TextInputWithLabelProps) {
return (
<div>
<div className="mb-2 block">
<Label htmlFor={id} value={name} />
</div>
<TextInput
id={id}
required={required}
placeholder={placeholder}
disabled={disabled}
value={value}
helperText={helperText}
/>
</div>
);
}
19 changes: 9 additions & 10 deletions frontend/src/contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { ReactNode, createContext, useEffect, useState } from 'react';
import { profile as getProfile } from '../util/api';
import { User, profile as getProfile } from '../util/api';
import { useCookies } from 'react-cookie';
import { decodeJwt, JWTPayload } from 'jose';
import { User } from '../util/user';

const MIN_WAIT_MS = 500;
const MIN_WAIT_MS = 200;

export interface AuthedContext {
loading: boolean;
Expand Down Expand Up @@ -49,10 +48,6 @@ export function AuthContextProvider({ children }: AuthContextProps) {
console.warn('Failure while syncing token and cookies', e);
removeCookie('token');
}
// A load attempted, and no token, make sure no cookie
} else if (!loading && !token && cookies.token) {
console.log('Clearing Cookie');
removeCookie('token');
} else if (!loading && token && !cookies.token) {
console.log('Updated token');
setCookie('token', token, {
Expand All @@ -66,15 +61,19 @@ export function AuthContextProvider({ children }: AuthContextProps) {
console.log('Getting profile claims');
const run = async () => {
const now = Date.now();
const res = await getProfile(token);
const { data, error } = await getProfile(token);

if (error) {
console.error(error);
return;
}

const json = await res.json();
const diff = Date.now() - now;
const minWait = MIN_WAIT_MS - diff;
const wait = Math.max(0, minWait);

setTimeout(() => {
setProfile(json);
setProfile(data);
setLoading(false);
}, wait);
};
Expand Down
Loading

0 comments on commit 70aa277

Please sign in to comment.