feat: API Rate Limiting 및 Swagger UI 구현 (#30)

- ASP.NET Core 내장 Rate Limiting (FixedWindow, IP 기반 분당 100회)
- 한도 초과 시 HTTP 429 + ApiResponse(에러코드 106) 반환
- Swashbuckle.AspNetCore 6.9.0 기반 Swagger UI 추가
- 도메인별 API 문서 그룹 (all, public, auth 등 10개)
- JWT Bearer 인증 UI (Authorize 버튼)
- X-Service-Code/X-API-KEY 커스텀 헤더 자동 표시 필터
- Microsoft.AspNetCore.OpenApi 제거 (Swashbuckle과 호환 충돌)

Closes #30
This commit is contained in:
SEAN 2026-02-09 17:11:46 +09:00
parent fb0da8669d
commit 58b94c6298
6 changed files with 162 additions and 7 deletions

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Swashbuckle.AspNetCore.Annotations;
using SPMS.Domain.Common; using SPMS.Domain.Common;
using SPMS.Infrastructure; using SPMS.Infrastructure;
@ -9,6 +10,7 @@ namespace SPMS.API.Controllers;
[ApiController] [ApiController]
[Route("v1/out")] [Route("v1/out")]
[AllowAnonymous] [AllowAnonymous]
[ApiExplorerSettings(GroupName = "public")]
public class PublicController : ControllerBase public class PublicController : ControllerBase
{ {
private readonly AppDbContext _dbContext; private readonly AppDbContext _dbContext;
@ -19,6 +21,7 @@ public class PublicController : ControllerBase
} }
[HttpPost("health")] [HttpPost("health")]
[SwaggerOperation(Summary = "서버 상태 확인", Description = "MariaDB, Redis, RabbitMQ 연결 상태를 확인합니다.")]
public async Task<IActionResult> HealthCheckAsync() public async Task<IActionResult> HealthCheckAsync()
{ {
var checks = new Dictionary<string, object>(); var checks = new Dictionary<string, object>();

View File

@ -47,8 +47,8 @@ public static class ApplicationBuilderExtensions
// -- 8. CORS (미구현) -- // -- 8. CORS (미구현) --
// app.UseCors("DefaultPolicy"); // app.UseCors("DefaultPolicy");
// -- 9. API 속도 제한 (미구현 — Issue #12) -- // -- 9. API 속도 제한 (IP별 분당 100회) --
// app.UseRateLimiter(); app.UseRateLimiter();
// -- 10. JWT 인증 -- // -- 10. JWT 인증 --
app.UseAuthentication(); app.UseAuthentication();
@ -65,10 +65,23 @@ public static class ApplicationBuilderExtensions
// -- 14. X-SPMS-TEST 샌드박스 모드 (미구현 — Issue #14) -- // -- 14. X-SPMS-TEST 샌드박스 모드 (미구현 — Issue #14) --
// app.UseMiddleware<SandboxMiddleware>(); // app.UseMiddleware<SandboxMiddleware>();
// -- 15. OpenAPI (개발 환경만) -- // -- 15. Swagger UI (개발 환경만) --
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.MapOpenApi(); app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint("/swagger/all/swagger.json", "SPMS API - 전체");
options.SwaggerEndpoint("/swagger/public/swagger.json", "공개 API");
options.SwaggerEndpoint("/swagger/auth/swagger.json", "인증 API");
options.SwaggerEndpoint("/swagger/account/swagger.json", "계정 API");
options.SwaggerEndpoint("/swagger/service/swagger.json", "서비스 API");
options.SwaggerEndpoint("/swagger/device/swagger.json", "디바이스 API");
options.SwaggerEndpoint("/swagger/message/swagger.json", "메시지 API");
options.SwaggerEndpoint("/swagger/push/swagger.json", "푸시 API");
options.SwaggerEndpoint("/swagger/stats/swagger.json", "통계 API");
options.SwaggerEndpoint("/swagger/file/swagger.json", "파일 API");
});
} }
// -- 16. 엔드포인트 매핑 -- // -- 16. 엔드포인트 매핑 --

View File

@ -0,0 +1,71 @@
using Microsoft.OpenApi.Models;
using SPMS.API.Filters;
namespace SPMS.API.Extensions;
public static class SwaggerExtensions
{
public static IServiceCollection AddSwaggerDocumentation(this IServiceCollection services)
{
services.AddEndpointsApiExplorer();
services.AddSwaggerGen(options =>
{
options.EnableAnnotations();
// API 문서 그룹
options.SwaggerDoc("all", new OpenApiInfo
{
Title = "SPMS API - 전체",
Version = "v1",
Description = "Stein Push Message Service API"
});
options.SwaggerDoc("public", new OpenApiInfo { Title = "공개 API", Version = "v1" });
options.SwaggerDoc("auth", new OpenApiInfo { Title = "인증 API", Version = "v1" });
options.SwaggerDoc("account", new OpenApiInfo { Title = "계정 API", Version = "v1" });
options.SwaggerDoc("service", new OpenApiInfo { Title = "서비스 API", Version = "v1" });
options.SwaggerDoc("device", new OpenApiInfo { Title = "디바이스 API", Version = "v1" });
options.SwaggerDoc("message", new OpenApiInfo { Title = "메시지 API", Version = "v1" });
options.SwaggerDoc("push", new OpenApiInfo { Title = "푸시 API", Version = "v1" });
options.SwaggerDoc("stats", new OpenApiInfo { Title = "통계 API", Version = "v1" });
options.SwaggerDoc("file", new OpenApiInfo { Title = "파일 API", Version = "v1" });
// 전체 문서에는 모든 API 포함, 나머지는 GroupName 기준 필터링
options.DocInclusionPredicate((docName, apiDesc) =>
{
if (docName == "all") return true;
return apiDesc.GroupName == docName;
});
// JWT Bearer 인증 UI
options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
{
Description = "JWT Bearer Token을 입력하세요. 예: {token}",
Name = "Authorization",
In = ParameterLocation.Header,
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT"
});
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme
{
Reference = new OpenApiReference
{
Type = ReferenceType.SecurityScheme,
Id = "Bearer"
}
},
Array.Empty<string>()
}
});
// SPMS 커스텀 헤더 필터
options.OperationFilter<SpmsHeaderOperationFilter>();
});
return services;
}
}

View File

@ -0,0 +1,44 @@
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace SPMS.API.Filters;
public class SpmsHeaderOperationFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
var relativePath = context.ApiDescription.RelativePath ?? "";
// /v1/out/* 공개 API는 커스텀 헤더 불필요
if (relativePath.StartsWith("v1/out"))
return;
operation.Parameters ??= new List<OpenApiParameter>();
// /v1/in/* 내부 API는 X-Service-Code 필요
if (relativePath.StartsWith("v1/in"))
{
operation.Parameters.Add(new OpenApiParameter
{
Name = "X-Service-Code",
In = ParameterLocation.Header,
Required = true,
Description = "서비스 식별 코드",
Schema = new OpenApiSchema { Type = "string" }
});
}
// /v1/in/device/* SDK/디바이스 전용 API는 X-API-KEY 필요
if (relativePath.StartsWith("v1/in/device"))
{
operation.Parameters.Add(new OpenApiParameter
{
Name = "X-API-KEY",
In = ParameterLocation.Header,
Required = true,
Description = "API 인증 키 (SDK/디바이스용)",
Schema = new OpenApiSchema { Type = "string" }
});
}
}
}

View File

@ -1,6 +1,8 @@
using System.Threading.RateLimiting;
using Serilog; using Serilog;
using SPMS.API.Extensions; using SPMS.API.Extensions;
using SPMS.Application; using SPMS.Application;
using SPMS.Domain.Common;
using SPMS.Infrastructure; using SPMS.Infrastructure;
var builder = WebApplication.CreateBuilder(new WebApplicationOptions var builder = WebApplication.CreateBuilder(new WebApplicationOptions
@ -19,13 +21,34 @@ builder.Services.AddInfrastructure(builder.Configuration);
// ===== 3. Presentation ===== // ===== 3. Presentation =====
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddOpenApi(); builder.Services.AddSwaggerDocumentation();
builder.Services.AddJwtAuthentication(builder.Configuration); builder.Services.AddJwtAuthentication(builder.Configuration);
builder.Services.AddAuthorizationPolicies(); builder.Services.AddAuthorizationPolicies();
// ===== 4. Rate Limiting =====
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, cancellationToken) =>
{
context.HttpContext.Response.ContentType = "application/json";
var response = ApiResponse.Fail(
ErrorCodes.LimitExceeded, "요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요.");
await context.HttpContext.Response.WriteAsJsonAsync(response, cancellationToken);
};
options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(httpContext =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromMinutes(1)
}));
});
var app = builder.Build(); var app = builder.Build();
// ===== 4. Middleware Pipeline ===== // ===== 5. Middleware Pipeline =====
app.UseMiddlewarePipeline(); app.UseMiddlewarePipeline();
app.Run(); app.Run();

View File

@ -11,7 +11,6 @@
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="FirebaseAdmin" Version="3.4.0" /> <PackageReference Include="FirebaseAdmin" Version="3.4.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -19,6 +18,8 @@
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" /> <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.0" /> <PackageReference Include="RabbitMQ.Client" Version="7.2.0" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.9.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.9.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>