diff --git a/SPMS.API/Controllers/PublicController.cs b/SPMS.API/Controllers/PublicController.cs index 96ee4e6..47ffc71 100644 --- a/SPMS.API/Controllers/PublicController.cs +++ b/SPMS.API/Controllers/PublicController.cs @@ -1,6 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using Swashbuckle.AspNetCore.Annotations; using SPMS.Domain.Common; using SPMS.Infrastructure; @@ -9,6 +10,7 @@ namespace SPMS.API.Controllers; [ApiController] [Route("v1/out")] [AllowAnonymous] +[ApiExplorerSettings(GroupName = "public")] public class PublicController : ControllerBase { private readonly AppDbContext _dbContext; @@ -19,6 +21,7 @@ public class PublicController : ControllerBase } [HttpPost("health")] + [SwaggerOperation(Summary = "서버 상태 확인", Description = "MariaDB, Redis, RabbitMQ 연결 상태를 확인합니다.")] public async Task HealthCheckAsync() { var checks = new Dictionary(); diff --git a/SPMS.API/Extensions/ApplicationBuilderExtensions.cs b/SPMS.API/Extensions/ApplicationBuilderExtensions.cs index bda0774..09a50a3 100644 --- a/SPMS.API/Extensions/ApplicationBuilderExtensions.cs +++ b/SPMS.API/Extensions/ApplicationBuilderExtensions.cs @@ -47,8 +47,8 @@ public static class ApplicationBuilderExtensions // -- 8. CORS (미구현) -- // app.UseCors("DefaultPolicy"); - // -- 9. API 속도 제한 (미구현 — Issue #12) -- - // app.UseRateLimiter(); + // -- 9. API 속도 제한 (IP별 분당 100회) -- + app.UseRateLimiter(); // -- 10. JWT 인증 -- app.UseAuthentication(); @@ -65,10 +65,23 @@ public static class ApplicationBuilderExtensions // -- 14. X-SPMS-TEST 샌드박스 모드 (미구현 — Issue #14) -- // app.UseMiddleware(); - // -- 15. OpenAPI (개발 환경만) -- + // -- 15. Swagger UI (개발 환경만) -- 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. 엔드포인트 매핑 -- diff --git a/SPMS.API/Extensions/SwaggerExtensions.cs b/SPMS.API/Extensions/SwaggerExtensions.cs new file mode 100644 index 0000000..1399f87 --- /dev/null +++ b/SPMS.API/Extensions/SwaggerExtensions.cs @@ -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() + } + }); + + // SPMS 커스텀 헤더 필터 + options.OperationFilter(); + }); + + return services; + } +} diff --git a/SPMS.API/Filters/SpmsHeaderOperationFilter.cs b/SPMS.API/Filters/SpmsHeaderOperationFilter.cs new file mode 100644 index 0000000..1787f3e --- /dev/null +++ b/SPMS.API/Filters/SpmsHeaderOperationFilter.cs @@ -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(); + + // /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" } + }); + } + } +} diff --git a/SPMS.API/Program.cs b/SPMS.API/Program.cs index 5e52736..26dfdd7 100644 --- a/SPMS.API/Program.cs +++ b/SPMS.API/Program.cs @@ -1,6 +1,8 @@ +using System.Threading.RateLimiting; using Serilog; using SPMS.API.Extensions; using SPMS.Application; +using SPMS.Domain.Common; using SPMS.Infrastructure; var builder = WebApplication.CreateBuilder(new WebApplicationOptions @@ -19,13 +21,34 @@ builder.Services.AddInfrastructure(builder.Configuration); // ===== 3. Presentation ===== builder.Services.AddControllers(); -builder.Services.AddOpenApi(); +builder.Services.AddSwaggerDocumentation(); builder.Services.AddJwtAuthentication(builder.Configuration); 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 => + RateLimitPartition.GetFixedWindowLimiter( + partitionKey: httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown", + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 100, + Window = TimeSpan.FromMinutes(1) + })); +}); + var app = builder.Build(); -// ===== 4. Middleware Pipeline ===== +// ===== 5. Middleware Pipeline ===== app.UseMiddlewarePipeline(); app.Run(); diff --git a/SPMS.API/SPMS.API.csproj b/SPMS.API/SPMS.API.csproj index 73bfcf7..ced04f4 100644 --- a/SPMS.API/SPMS.API.csproj +++ b/SPMS.API/SPMS.API.csproj @@ -11,7 +11,6 @@ - all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -19,6 +18,8 @@ + +