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:
2026-10-03 15:26:47 +02:00
parent b2c9be72c3
commit 341241a52f
36 changed files with 7386 additions and 42 deletions

View 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;
}
}