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:
parent
fb0da8669d
commit
58b94c6298
|
|
@ -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>();
|
||||||
|
|
|
||||||
|
|
@ -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. 엔드포인트 매핑 --
|
||||||
|
|
|
||||||
71
SPMS.API/Extensions/SwaggerExtensions.cs
Normal file
71
SPMS.API/Extensions/SwaggerExtensions.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
SPMS.API/Filters/SpmsHeaderOperationFilter.cs
Normal file
44
SPMS.API/Filters/SpmsHeaderOperationFilter.cs
Normal 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" }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user