From 3ad28360885dd24d4b7aa1e920cb34558cb12b46 Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Fri, 27 Feb 2026 12:58:31 +0100 Subject: [PATCH] feat(api): add DELETE /v2/characters/{id} route, v2 test coverage, and OpenAPI spec Add REST-idiomatic DELETE method as alias for POST .../delete. Add 8 router-level tests exercising Bearer auth, invalid tokens, soft delete, export body decoding, and MaxLauncherHR capping. Create OpenAPI 3.1.0 specification covering all v2 endpoints. --- docs/openapi.yaml | 576 +++++++++++++++++++++++++++++++++++++ server/api/api_server.go | 1 + server/api/routing_test.go | 196 +++++++++++++ 3 files changed, 773 insertions(+) create mode 100644 docs/openapi.yaml diff --git a/docs/openapi.yaml b/docs/openapi.yaml new file mode 100644 index 000000000..a0875c0f0 --- /dev/null +++ b/docs/openapi.yaml @@ -0,0 +1,576 @@ +openapi: 3.1.0 +info: + title: Erupe API + description: REST API for the Erupe Monster Hunter Frontier server emulator. + version: 2.0.0 + license: + name: MIT + +servers: + - url: http://localhost:8080 + description: Local development server + +paths: + /v2/login: + post: + summary: Authenticate user + operationId: login + tags: [auth] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/LoginRequest" + responses: + "200": + description: Successful authentication + content: + application/json: + schema: + $ref: "#/components/schemas/AuthData" + "400": + description: Invalid credentials or malformed request + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + invalid_username: + value: + error: invalid_username + message: Username not found + invalid_password: + value: + error: invalid_password + message: Incorrect password + invalid_request: + value: + error: invalid_request + message: Malformed request body + "500": + $ref: "#/components/responses/InternalError" + + /v2/register: + post: + summary: Create new user account + operationId: register + tags: [auth] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/RegisterRequest" + responses: + "200": + description: Account created successfully + content: + application/json: + schema: + $ref: "#/components/schemas/AuthData" + "400": + description: Validation error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + examples: + missing_fields: + value: + error: missing_fields + message: Username and password required + username_exists: + value: + error: username_exists + message: Username already taken + invalid_request: + value: + error: invalid_request + message: Malformed request body + "500": + $ref: "#/components/responses/InternalError" + + /v2/launcher: + get: + summary: Get launcher UI data + operationId: getLauncher + tags: [public] + responses: + "200": + description: Launcher banners, messages, and links + content: + application/json: + schema: + $ref: "#/components/schemas/LauncherResponse" + + /v2/version: + get: + summary: Get server version + operationId: getVersion + tags: [public] + responses: + "200": + description: Server version information + content: + application/json: + schema: + $ref: "#/components/schemas/VersionResponse" + + /v2/health: + get: + summary: Health check + operationId: getHealth + tags: [public] + responses: + "200": + description: Server is healthy + content: + application/json: + schema: + $ref: "#/components/schemas/HealthResponse" + "503": + description: Server is unhealthy + content: + application/json: + schema: + $ref: "#/components/schemas/HealthResponse" + + /v2/server/status: + get: + summary: Get server status + operationId: getServerStatus + tags: [public] + responses: + "200": + description: Current server status including events and MezFes schedule + content: + application/json: + schema: + $ref: "#/components/schemas/ServerStatusResponse" + + /v2/characters: + post: + summary: Create a new character + operationId: createCharacter + tags: [characters] + security: + - bearerAuth: [] + responses: + "200": + description: Character created + content: + application/json: + schema: + $ref: "#/components/schemas/Character" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalError" + + /v2/characters/{id}/delete: + post: + summary: Delete a character (POST form) + operationId: deleteCharacterPost + tags: [characters] + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/characterId" + responses: + "200": + description: Character deleted + content: + application/json: + schema: + type: object + "401": + $ref: "#/components/responses/Unauthorized" + "400": + description: Invalid character ID + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + $ref: "#/components/responses/InternalError" + + /v2/characters/{id}: + delete: + summary: Delete a character + operationId: deleteCharacter + tags: [characters] + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/characterId" + responses: + "200": + description: Character deleted + content: + application/json: + schema: + type: object + "401": + $ref: "#/components/responses/Unauthorized" + "400": + description: Invalid character ID + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + $ref: "#/components/responses/InternalError" + + /v2/characters/{id}/export: + get: + summary: Export character save data + operationId: exportSave + tags: [characters] + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/characterId" + responses: + "200": + description: Full character data for backup + content: + application/json: + schema: + $ref: "#/components/schemas/ExportData" + "401": + $ref: "#/components/responses/Unauthorized" + "400": + description: Invalid character ID + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "500": + $ref: "#/components/responses/InternalError" + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: Session token returned by /v2/login or /v2/register + + parameters: + characterId: + name: id + in: path + required: true + schema: + type: integer + format: uint32 + description: Character ID + + responses: + Unauthorized: + description: Missing or invalid Bearer token + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + error: unauthorized + message: Invalid or expired token + InternalError: + description: Internal server error + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + example: + error: internal_error + message: Internal server error + + schemas: + ErrorResponse: + type: object + required: [error, message] + properties: + error: + type: string + description: Machine-readable error code + examples: + - invalid_username + - invalid_password + - username_exists + - missing_fields + - invalid_request + - unauthorized + - internal_error + message: + type: string + description: Human-readable error description + + LoginRequest: + type: object + required: [username, password] + properties: + username: + type: string + password: + type: string + + RegisterRequest: + type: object + required: [username, password] + properties: + username: + type: string + password: + type: string + + AuthData: + type: object + required: + - currentTs + - expiryTs + - entranceCount + - notices + - user + - characters + - courses + - mezFes + - patchServer + properties: + currentTs: + type: integer + format: uint32 + description: Current server timestamp (Unix seconds) + expiryTs: + type: integer + format: uint32 + description: Return expiry timestamp (Unix seconds) + entranceCount: + type: integer + format: uint32 + notices: + type: array + items: + type: string + user: + $ref: "#/components/schemas/User" + characters: + type: array + items: + $ref: "#/components/schemas/Character" + courses: + type: array + items: + $ref: "#/components/schemas/CourseInfo" + mezFes: + oneOf: + - $ref: "#/components/schemas/MezFes" + - type: "null" + patchServer: + type: string + + User: + type: object + required: [tokenId, token, rights] + properties: + tokenId: + type: integer + format: uint32 + token: + type: string + rights: + type: integer + format: uint32 + + Character: + type: object + required: [id, name, isFemale, weapon, hr, gr, lastLogin, returning] + properties: + id: + type: integer + format: uint32 + name: + type: string + isFemale: + type: boolean + weapon: + type: integer + format: uint32 + hr: + type: integer + format: uint32 + gr: + type: integer + format: uint32 + lastLogin: + type: integer + format: int32 + description: Unix timestamp of last login + returning: + type: boolean + description: True if character has not logged in for 90+ days + + CourseInfo: + type: object + required: [id, name] + properties: + id: + type: integer + format: uint16 + name: + type: string + + MezFes: + type: object + required: [id, start, end, soloTickets, groupTickets, stalls] + properties: + id: + type: integer + format: uint32 + start: + type: integer + format: uint32 + end: + type: integer + format: uint32 + soloTickets: + type: integer + format: uint32 + groupTickets: + type: integer + format: uint32 + stalls: + type: array + items: + type: integer + format: uint32 + + LauncherResponse: + type: object + required: [banners, messages, links] + properties: + banners: + type: array + items: + $ref: "#/components/schemas/Banner" + messages: + type: array + items: + $ref: "#/components/schemas/Message" + links: + type: array + items: + $ref: "#/components/schemas/Link" + + Banner: + type: object + required: [src, link] + properties: + src: + type: string + description: Displayed image URL + link: + type: string + description: Link accessed on click + + Message: + type: object + required: [message, date, kind, link] + properties: + message: + type: string + description: Displayed message + date: + type: integer + format: int64 + description: Displayed date (Unix timestamp) + kind: + type: integer + description: "0 = Default, 1 = New" + enum: [0, 1] + link: + type: string + description: Link accessed on click + + Link: + type: object + required: [name, icon, link] + properties: + name: + type: string + description: Displayed name + icon: + type: string + description: Displayed icon (rendered as monochrome if transparent) + link: + type: string + description: Link accessed on click + + VersionResponse: + type: object + required: [clientMode, name] + properties: + clientMode: + type: string + description: Supported game client version (e.g. "ZZ") + name: + type: string + description: Server software name + examples: ["Erupe-CE"] + + HealthResponse: + type: object + required: [status] + properties: + status: + type: string + enum: [ok, unhealthy] + error: + type: string + description: Error description (present only when unhealthy) + + ServerStatusResponse: + type: object + required: [mezFes, featuredWeapon, events] + properties: + mezFes: + oneOf: + - $ref: "#/components/schemas/MezFes" + - type: "null" + featuredWeapon: + oneOf: + - $ref: "#/components/schemas/FeatureWeaponInfo" + - type: "null" + events: + $ref: "#/components/schemas/EventStatus" + + FeatureWeaponInfo: + type: object + required: [startTime, activeFeatures] + properties: + startTime: + type: integer + format: uint32 + description: Unix timestamp + activeFeatures: + type: integer + format: uint32 + description: Bitmask of active featured weapons + + EventStatus: + type: object + required: [festaActive, divaActive] + properties: + festaActive: + type: boolean + divaActive: + type: boolean + + ExportData: + type: object + required: [character] + properties: + character: + type: object + additionalProperties: true + description: Full character database row as key-value pairs diff --git a/server/api/api_server.go b/server/api/api_server.go index f40176ef0..6e106a088 100644 --- a/server/api/api_server.go +++ b/server/api/api_server.go @@ -85,6 +85,7 @@ func (s *APIServer) Start() error { v2Auth.Use(s.AuthMiddleware) v2Auth.HandleFunc("/characters", s.CreateCharacter).Methods("POST") v2Auth.HandleFunc("/characters/{id}/delete", s.DeleteCharacter).Methods("POST") + v2Auth.HandleFunc("/characters/{id}", s.DeleteCharacter).Methods("DELETE") v2Auth.HandleFunc("/characters/{id}/export", s.ExportSave).Methods("GET") handler := handlers.CORS( diff --git a/server/api/routing_test.go b/server/api/routing_test.go index c336b3838..d72f7567b 100644 --- a/server/api/routing_test.go +++ b/server/api/routing_test.go @@ -40,6 +40,7 @@ func newTestRouter(s *APIServer) *mux.Router { v2Auth.Use(s.AuthMiddleware) v2Auth.HandleFunc("/characters", s.CreateCharacter).Methods("POST") v2Auth.HandleFunc("/characters/{id}/delete", s.DeleteCharacter).Methods("POST") + v2Auth.HandleFunc("/characters/{id}", s.DeleteCharacter).Methods("DELETE") v2Auth.HandleFunc("/characters/{id}/export", s.ExportSave).Methods("GET") v2.HandleFunc("/server/status", s.ServerStatus).Methods("GET") @@ -298,6 +299,201 @@ func TestV2ServerStatusRoute_WithEvents(t *testing.T) { } } +func TestV2CreateCharacter_InvalidToken(t *testing.T) { + logger := NewTestLogger(t) + server := &APIServer{ + logger: logger, + erupeConfig: NewTestConfig(), + sessionRepo: &mockAPISessionRepo{ + userIDErr: sql.ErrNoRows, + }, + } + + router := newTestRouter(server) + + req := httptest.NewRequest("POST", "/v2/characters", nil) + req.Header.Set("Authorization", "Bearer bad-token") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("POST /v2/characters (bad token): status = %d, want 401", rec.Code) + } +} + +func TestV2DeleteCharacter_InvalidToken(t *testing.T) { + logger := NewTestLogger(t) + server := &APIServer{ + logger: logger, + erupeConfig: NewTestConfig(), + sessionRepo: &mockAPISessionRepo{ + userIDErr: sql.ErrNoRows, + }, + } + + router := newTestRouter(server) + + req := httptest.NewRequest("POST", "/v2/characters/5/delete", nil) + req.Header.Set("Authorization", "Bearer bad-token") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("POST /v2/characters/5/delete (bad token): status = %d, want 401", rec.Code) + } +} + +func TestV2DeleteCharacter_DELETE(t *testing.T) { + logger := NewTestLogger(t) + server := &APIServer{ + logger: logger, + erupeConfig: NewTestConfig(), + sessionRepo: &mockAPISessionRepo{userID: 1}, + charRepo: &mockAPICharacterRepo{isNewResult: true}, + } + + router := newTestRouter(server) + + req := httptest.NewRequest("DELETE", "/v2/characters/5", nil) + req.Header.Set("Authorization", "Bearer valid-token") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("DELETE /v2/characters/5: status = %d, want 200", rec.Code) + } +} + +func TestV2DeleteCharacter_DELETE_InvalidToken(t *testing.T) { + logger := NewTestLogger(t) + server := &APIServer{ + logger: logger, + erupeConfig: NewTestConfig(), + sessionRepo: &mockAPISessionRepo{ + userIDErr: sql.ErrNoRows, + }, + } + + router := newTestRouter(server) + + req := httptest.NewRequest("DELETE", "/v2/characters/5", nil) + req.Header.Set("Authorization", "Bearer bad-token") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("DELETE /v2/characters/5 (bad token): status = %d, want 401", rec.Code) + } +} + +func TestV2DeleteCharacter_Finalized(t *testing.T) { + logger := NewTestLogger(t) + server := &APIServer{ + logger: logger, + erupeConfig: NewTestConfig(), + sessionRepo: &mockAPISessionRepo{userID: 1}, + charRepo: &mockAPICharacterRepo{isNewResult: false}, + } + + router := newTestRouter(server) + + req := httptest.NewRequest("POST", "/v2/characters/5/delete", nil) + req.Header.Set("Authorization", "Bearer valid-token") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("POST /v2/characters/5/delete (finalized): status = %d, want 200", rec.Code) + } +} + +func TestV2ExportSave_InvalidToken(t *testing.T) { + logger := NewTestLogger(t) + server := &APIServer{ + logger: logger, + erupeConfig: NewTestConfig(), + sessionRepo: &mockAPISessionRepo{ + userIDErr: sql.ErrNoRows, + }, + } + + router := newTestRouter(server) + + req := httptest.NewRequest("GET", "/v2/characters/1/export", nil) + req.Header.Set("Authorization", "Bearer bad-token") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("GET /v2/characters/1/export (bad token): status = %d, want 401", rec.Code) + } +} + +func TestV2ExportSave_VerifyBody(t *testing.T) { + logger := NewTestLogger(t) + server := &APIServer{ + logger: logger, + erupeConfig: NewTestConfig(), + sessionRepo: &mockAPISessionRepo{userID: 1}, + charRepo: &mockAPICharacterRepo{ + exportResult: map[string]interface{}{"name": "Hunter", "hr": float64(99)}, + }, + } + + router := newTestRouter(server) + + req := httptest.NewRequest("GET", "/v2/characters/1/export", nil) + req.Header.Set("Authorization", "Bearer valid-token") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET /v2/characters/1/export: status = %d, want 200", rec.Code) + } + + var export ExportData + if err := json.NewDecoder(rec.Body).Decode(&export); err != nil { + t.Fatalf("decode error: %v", err) + } + if export.Character["name"] != "Hunter" { + t.Errorf("character name = %v, want Hunter", export.Character["name"]) + } +} + +func TestV2CreateCharacter_DebugHR(t *testing.T) { + logger := NewTestLogger(t) + conf := NewTestConfig() + conf.DebugOptions.MaxLauncherHR = true + + server := &APIServer{ + logger: logger, + erupeConfig: conf, + sessionRepo: &mockAPISessionRepo{userID: 1}, + charRepo: &mockAPICharacterRepo{ + newCharacter: Character{ID: 5, Name: "NewChar", HR: 999}, + }, + } + + router := newTestRouter(server) + + req := httptest.NewRequest("POST", "/v2/characters", nil) + req.Header.Set("Authorization", "Bearer valid-token") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("POST /v2/characters: status = %d, want 200", rec.Code) + } + + var char Character + if err := json.NewDecoder(rec.Body).Decode(&char); err != nil { + t.Fatalf("decode error: %v", err) + } + if char.HR != 7 { + t.Errorf("HR = %d, want 7 (capped by MaxLauncherHR)", char.HR) + } +} + func TestLegacyRoutesStillWork(t *testing.T) { logger := NewTestLogger(t) server := &APIServer{