openapi: 3.0.3
info:
  title: APK Obfuscator Server API
  description: >
    On-prem server wrapping AhnLab ObfWrapper. Files are referenced by opaque
    fileId (upload first), jobs run on a bounded queue, and outcome is inferred
    from log + artifacts (the jar exits 0 even on error). Served at /openapi.yaml.
  version: 0.1.0
servers:
  - url: http://localhost:8787
    description: Default (adjust host/port to your deployment)

security:
  - bearerAuth: []

tags:
  - name: health
  - name: uploads
  - name: analyze
  - name: jobs
  - name: ops

paths:
  /healthz:
    get:
      tags: [health]
      summary: Liveness (open)
      security: []
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: object, properties: { status: { type: string, example: ok } } } } } }
  /readyz:
    get:
      tags: [health]
      summary: Readiness (open) — is the ObfWrapper jar present
      security: []
      responses:
        "200": { description: Ready, content: { application/json: { schema: { $ref: "#/components/schemas/ReadyResponse" } } } }
        "503": { description: Not ready (jar missing) }
  /api/version:
    get:
      tags: [ops]
      summary: Server version + uptime (open)
      security: []
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/VersionResponse" } } } }
  /metrics:
    get:
      tags: [ops]
      summary: Prometheus metrics (open, text exposition)
      security: []
      responses:
        "200": { description: OK, content: { text/plain: { schema: { type: string } } } }
  /api/stats:
    get:
      tags: [ops]
      summary: Aggregate job + disk stats
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/StatsResponse" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/upload:
    post:
      tags: [uploads]
      summary: Upload a file, get an opaque fileId
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file: { type: string, format: binary }
      responses:
        "200": { description: Stored, content: { application/json: { schema: { $ref: "#/components/schemas/UploadResponse" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
  /api/upload/{fileId}:
    get:
      tags: [uploads]
      summary: Check whether a fileId still resolves (staging TTL)
      parameters: [ { $ref: "#/components/parameters/FileId" } ]
      responses:
        "200": { description: Exists, content: { application/json: { schema: { $ref: "#/components/schemas/UploadResponse" } } } }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/analyze:
    post:
      tags: [analyze]
      summary: Analyze an uploaded APK/AAB — basics + packages/classes (DEX) + native libs
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/AnalyzeRequest" } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/ApkInventory" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }

  /api/jobs:
    get:
      tags: [jobs]
      summary: List jobs (enriched summaries)
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { $ref: "#/components/schemas/JobSummary" } } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
    post:
      tags: [jobs]
      summary: Submit an obfuscation job (references uploaded fileIds)
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/JobRequest" } } }
      responses:
        "202": { description: Accepted, content: { application/json: { schema: { $ref: "#/components/schemas/SubmitResponse" } } } }
        "429": { description: Queue at capacity, content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  /api/jobs/cleanup:
    post:
      tags: [jobs]
      summary: Bulk-delete all terminal jobs
      responses:
        "200": { description: Deleted count, content: { application/json: { schema: { type: object, properties: { deleted: { type: integer } } } } } }
  /api/jobs/{id}:
    get:
      tags: [jobs]
      summary: Full job record (masked options, basics, guide)
      parameters: [ { $ref: "#/components/parameters/JobId" } ]
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/JobRecord" } } } }
        "404": { $ref: "#/components/responses/NotFound" }
    delete:
      tags: [jobs]
      summary: Delete a job (terminal only)
      parameters: [ { $ref: "#/components/parameters/JobId" } ]
      responses:
        "200": { description: Deleted }
        "404": { $ref: "#/components/responses/NotFound" }
        "409": { description: Job is running — cancel first }
  /api/jobs/{id}/cancel:
    post:
      tags: [jobs]
      summary: Cancel a queued/running job
      parameters: [ { $ref: "#/components/parameters/JobId" } ]
      responses:
        "200": { description: Cancelled }
        "409": { description: Not cancellable }
  /api/jobs/{id}/artifacts:
    get:
      tags: [jobs]
      summary: List artifact filenames
      parameters: [ { $ref: "#/components/parameters/JobId" } ]
      responses:
        "200": { description: OK, content: { application/json: { schema: { type: array, items: { type: string } } } } }
  /api/jobs/{id}/artifacts.zip:
    get:
      tags: [jobs]
      summary: Download all artifacts as a ZIP
      parameters: [ { $ref: "#/components/parameters/JobId" } ]
      responses:
        "200": { description: OK, content: { application/zip: { schema: { type: string, format: binary } } } }
        "404": { description: No artifacts }
  /api/jobs/{id}/artifacts/{name}:
    get:
      tags: [jobs]
      summary: Download one artifact
      parameters:
        - { $ref: "#/components/parameters/JobId" }
        - { name: name, in: path, required: true, schema: { type: string } }
      responses:
        "200": { description: OK, content: { application/octet-stream: { schema: { type: string, format: binary } } } }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/jobs/{id}/log:
    get:
      tags: [jobs]
      summary: Poll the job log (line cursor + terminal state)
      description: >
        Returns log lines appended since `from` plus the current terminal state. Replaces the old
        SSE stream — managed gateways (e.g. the ECS Express gateway) buffer a long-lived streaming
        response whole, so logs only appeared after the job finished. Poll with `?from=nextFrom`
        (start at 0) until `done` is true, ~1s between requests.
      parameters:
        - { $ref: "#/components/parameters/JobId" }
        - { name: from, in: query, required: false, schema: { type: integer }, description: Line index to resume from (use the previous response's nextFrom; default 0) }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/JobLogSnapshot" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/diagnose/start:
    post:
      tags: [analyze]
      summary: Start a background exposure diagnosis of an uploaded APK/AAB
      description: >
        Kicks off analysis in the background and returns a `diagId` to poll. Replaces the old SSE
        diagnose stream (buffered whole by managed gateways).
      requestBody:
        required: true
        content: { application/json: { schema: { $ref: "#/components/schemas/AnalyzeRequest" } } }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/DiagStartResponse" } } } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
  /api/diagnose/{diagId}:
    get:
      tags: [analyze]
      summary: Poll a background diagnosis (event cursor)
      description: >
        Returns diagnosis events accumulated since `from` (basics → progress → per-category counts →
        decompiled code samples). Poll with `?from=nextFrom` (start at 0) until `done` is true.
      parameters:
        - { name: diagId, in: path, required: true, schema: { type: string } }
        - { name: from, in: query, required: false, schema: { type: integer }, description: Event index to resume from (use the previous response's nextFrom; default 0) }
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/DiagPoll" } } } }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Unknown or expired diagId, content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: One of the API_TOKENS values.
  parameters:
    JobId: { name: id, in: path, required: true, schema: { type: string } }
    FileId: { name: fileId, in: path, required: true, schema: { type: string } }
  responses:
    Unauthorized: { description: Invalid/missing token, content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    NotFound: { description: Not found, content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
    BadRequest: { description: Bad request, content: { application/json: { schema: { $ref: "#/components/schemas/ErrorResponse" } } } }
  schemas:
    JobStatus:
      type: string
      enum: [QUEUED, RUNNING, SUCCEEDED, FAILED, CANCELLED]
    ErrorResponse:
      type: object
      properties: { error: { type: string } }
    ReadyResponse:
      type: object
      properties:
        status: { type: string }
        obfJar: { type: boolean }
        python3: { type: boolean }
    VersionResponse:
      type: object
      properties:
        version: { type: string, example: "0.1.0" }
        startedAt: { type: integer, format: int64 }
        uptimeSeconds: { type: integer, format: int64 }
    StatsResponse:
      type: object
      properties:
        total: { type: integer }
        byStatus: { type: object, additionalProperties: { type: integer } }
        queueDepth: { type: integer }
        completed: { type: integer }
        succeeded: { type: integer }
        successRate: { type: number, format: double }
        avgDurationSeconds: { type: number, format: double }
        dataDirBytes: { type: integer, format: int64 }
        diskFreeBytes: { type: integer, format: int64 }
        diskTotalBytes: { type: integer, format: int64 }
    UploadResponse:
      type: object
      properties:
        fileId: { type: string }
        name: { type: string }
    AnalyzeRequest:
      type: object
      required: [fileId]
      properties: { fileId: { type: string } }
    ApkBasics:
      type: object
      properties:
        pkg: { type: string, nullable: true }
        versionName: { type: string, nullable: true }
        versionCode: { type: string, nullable: true }
    ApkInventory:
      type: object
      properties:
        basics: { $ref: "#/components/schemas/ApkBasics" }
        packages: { type: array, items: { type: string } }
        classes: { type: array, items: { type: string } }
        libs: { type: array, items: { type: string } }
    JobRequest:
      type: object
      required: [inputFileId, codeProdFileId]
      properties:
        inputFileId: { type: string, description: fileId from /api/upload }
        codeProdFileId: { type: string }
        proConfigFileId: { type: string, nullable: true }
        license: { type: string, nullable: true }
        obfType: { type: string, nullable: true, enum: [default, size-optimized, full, spring-boot] }
        name: { type: boolean }
        packer: { type: boolean, description: "Ignored for .jar input" }
        native: { type: boolean, description: "Ignored for .jar input; needs python3" }
        msdk: { type: boolean, description: "Ignored for .jar input" }
        skipUpdate: { type: boolean }
        msdkLicense: { type: string, nullable: true }
        targetObf: { type: string, nullable: true }
        targetName: { type: string, nullable: true }
        targetPacker: { type: string, nullable: true }
        excludeObf: { type: string, nullable: true }
        excludePacker: { type: string, nullable: true }
        excludeSo: { type: string, nullable: true }
        extra: { type: string, nullable: true }
    SubmitResponse:
      type: object
      properties:
        id: { type: string }
        status: { $ref: "#/components/schemas/JobStatus" }
        position: { type: integer }
    ReturnCode:
      type: object
      properties:
        code: { type: integer }
        name: { type: string }
    JobSummary:
      type: object
      properties:
        id: { type: string }
        status: { $ref: "#/components/schemas/JobStatus" }
        createdAt: { type: integer, format: int64 }
        finishedAt: { type: integer, format: int64, nullable: true }
        returnCode: { type: integer, nullable: true }
        pkg: { type: string, nullable: true }
        versionName: { type: string, nullable: true }
        versionCode: { type: string, nullable: true }
        obfType: { type: string, nullable: true }
        inputs: { type: array, items: { type: string } }
        artifacts: { type: array, items: { type: string } }
    JobRecord:
      type: object
      properties:
        id: { type: string }
        status: { $ref: "#/components/schemas/JobStatus" }
        createdAt: { type: integer, format: int64 }
        startedAt: { type: integer, format: int64, nullable: true }
        finishedAt: { type: integer, format: int64, nullable: true }
        returnCode: { type: integer, nullable: true }
        guide: { $ref: "#/components/schemas/ReturnCode" }
        artifact: { type: string, nullable: true }
        errorLine: { type: string, nullable: true }
        basics: { $ref: "#/components/schemas/ApkBasics" }
        maskedOptions: { type: object, additionalProperties: { type: string }, description: "Secrets masked as ****" }
    LogEntry:
      type: object
      properties:
        t: { type: integer, format: int64, description: Epoch millis }
        level: { type: string, description: INFO | WARN | ERR }
        msg: { type: string }
    JobLogSnapshot:
      type: object
      description: Poll response for GET /api/jobs/{id}/log.
      properties:
        lines: { type: array, items: { $ref: "#/components/schemas/LogEntry" }, description: Lines appended since the requested from index }
        nextFrom: { type: integer, description: "Pass as ?from= on the next poll" }
        done: { type: boolean, description: True once the job reached a terminal state }
        status: { $ref: "#/components/schemas/JobStatus" }
        returnCode: { type: integer, nullable: true }
        guide: { $ref: "#/components/schemas/ReturnCode" }
        artifact: { type: string, nullable: true }
    DiagStartResponse:
      type: object
      properties:
        diagId: { type: string }
    DiagEvent:
      type: object
      properties:
        type: { type: string, description: basics | progress | category | code }
        data: { type: string, description: JSON payload for the event type }
    DiagPoll:
      type: object
      description: Poll response for GET /api/diagnose/{diagId}.
      properties:
        events: { type: array, items: { $ref: "#/components/schemas/DiagEvent" } }
        nextFrom: { type: integer, description: "Pass as ?from= on the next poll" }
        done: { type: boolean }
        error: { type: string, nullable: true }
