feat(api): REST API with OpenAPI for other apps, behind Pireon OAuth
A REST adapter next to GraphQL (presenters/http) that calls the same application services, so validation, permissions and domain errors are the same: projects (members, board, stats, activity), issues (search, workflow, assignee, release, links, comments, history), comments, releases and users. Projects and issues can be addressed by id or key (WEB, WEB-12). - Swagger UI at /api/docs, document at /api/docs-json and apps/api/openapi.json (rewritten by `nx serve api`, like schema.gql) for client generation. - Every route but health/auth/webhook needs a Pireon token: the document declares Pireon OAuth2 (authorization code + PKCE, password) through the /api/auth/token proxy, and a bearer scheme for apps with their own client. - configureApp() shares main.ts setup with the e2e, which now also covers REST. - SWAGGER_ENABLED / OPENAPI_FILE config; docs 03/05 and library READMEs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
101
libs/api/identity/presenters/src/lib/http/users.controller.ts
Normal file
101
libs/api/identity/presenters/src/lib/http/users.controller.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiProperty,
|
||||
ApiPropertyOptional,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { IdentityService } from '@task-stream/api/identity/application';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import { ApiReadErrors } from '@task-stream/api/shared/presenters';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
AccessToken,
|
||||
ApiPireonAuth,
|
||||
CurrentUser,
|
||||
} from '../auth/auth.decorators';
|
||||
|
||||
export class UserDto {
|
||||
@ApiProperty({ description: 'Pireon user id' }) id!: string;
|
||||
@ApiProperty() name!: string;
|
||||
@ApiProperty() email!: string;
|
||||
@ApiProperty() isActive!: boolean;
|
||||
}
|
||||
|
||||
export class MeDto extends UserDto {
|
||||
@ApiProperty({ type: [String], description: 'Global roles in Pireon' })
|
||||
roles!: string[];
|
||||
@ApiProperty({
|
||||
type: [String],
|
||||
description: 'Roles inside the task-stream OAuth client',
|
||||
})
|
||||
appRoles!: string[];
|
||||
}
|
||||
|
||||
export class UserSearchQueryDto {
|
||||
@ApiPropertyOptional({ description: 'Name or email', default: '' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
search = '';
|
||||
|
||||
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit = 20;
|
||||
}
|
||||
|
||||
/** REST adapter of identity: who I am and Pireon's user directory. */
|
||||
@ApiTags('Users')
|
||||
@ApiPireonAuth()
|
||||
@Controller('users')
|
||||
export class UsersController {
|
||||
constructor(private readonly identity: IdentityService) {}
|
||||
|
||||
@Get('me')
|
||||
@ApiOperation({ summary: 'The user behind the access token' })
|
||||
@ApiOkResponse({ type: MeDto })
|
||||
@ApiReadErrors()
|
||||
me(@CurrentUser() user: User): MeDto {
|
||||
return user.toSnapshot();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({
|
||||
summary: 'Search Pireon users (to assign tickets or add members)',
|
||||
description: 'Needs the `users:read` scope in the access token.',
|
||||
})
|
||||
@ApiOkResponse({ type: [UserDto] })
|
||||
@ApiReadErrors()
|
||||
search(@Query() query: UserSearchQueryDto, @AccessToken() token: string) {
|
||||
return this.identity.searchUsers(query.search, token, query.limit);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'A Pireon user' })
|
||||
@ApiOkResponse({ type: UserDto })
|
||||
@ApiReadErrors()
|
||||
async findOne(@Param('id') id: string, @AccessToken() token: string) {
|
||||
const user = await this.identity.findUser(id, token);
|
||||
if (!user) throw new NotFoundException('User not found');
|
||||
return user;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user