{
  "swagger": "2.0",
  "info": {
    "title": "Upload service",
    "version": "0.1507.0",
    "description": "Send your API key as a bearer token: `Authorization: Bearer <api key>`."
  },
  "consumes": [
    "application/json"
  ],
  "produces": [
    "application/json"
  ],
  "securityDefinitions": {
    "ApiKey": {
      "type": "apiKey",
      "in": "header",
      "name": "Authorization",
      "description": "Your API key as a bearer token: `Authorization: Bearer <api key>`."
    }
  },
  "security": [
    {
      "ApiKey": []
    }
  ],
  "paths": {
    "/upload/v1/multipart-upload": {
      "post": {
        "operationId": "UploadService_initiateMultipartUpload",
        "summary": "Initiate multipart upload",
        "tags": [
          "Upload Service"
        ],
        "description": "Initiates a multipart upload to object storage. Returns an uploadId that should be used with listParts, signPart, and completeMultipartUpload.",
        "parameters": [
          {
            "name": "uploadRequest",
            "in": "body",
            "schema": {
              "$ref": "#/definitions/InitiateMultipartUploadRequest"
            },
            "required": true,
            "x-example": {
              "filename": "<filename>",
              "filetype": "<filetype>"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A successful response.",
            "schema": {
              "$ref": "#/definitions/InitiateMultipartUploadResponse"
            },
            "x-example": {
              "uploadId": "<upload id>",
              "key": "<key>",
              "bucket": "<bucket>"
            }
          }
        }
      }
    },
    "/upload/v1/multipart-upload/{uploadId}": {
      "get": {
        "operationId": "UploadService_listParts",
        "summary": "List parts",
        "tags": [
          "Upload Service"
        ],
        "description": "Lists the parts that have been uploaded for a given uploadId.",
        "parameters": [
          {
            "name": "uploadId",
            "in": "path",
            "type": "string",
            "required": true
          },
          {
            "name": "key",
            "in": "query",
            "type": "string",
            "required": true
          },
          {
            "name": "bucket",
            "in": "query",
            "type": "string",
            "required": false,
            "description": "The opaque destination handle returned as `bucket` from InitiateMultipartUploadResponse, passed back unchanged. Omit only for an upload opened without a destination."
          }
        ],
        "responses": {
          "200": {
            "description": "A successful response.",
            "schema": {
              "type": "array",
              "items": {
                "$ref": "#/definitions/PartWithSize"
              }
            },
            "x-example": [
              {
                "partNumber": 1,
                "etag": "<etag>",
                "size": 1
              }
            ]
          }
        }
      },
      "post": {
        "operationId": "UploadService_signPart",
        "summary": "Sign part",
        "tags": [
          "Upload Service"
        ],
        "description": "Signs an upload request for a single part. Returns a URL that will execute the upload without further authentication.",
        "parameters": [
          {
            "name": "uploadId",
            "in": "path",
            "type": "string",
            "required": true
          },
          {
            "name": "key",
            "in": "query",
            "type": "string",
            "required": true
          },
          {
            "name": "partNumber",
            "in": "query",
            "type": "integer",
            "format": "int32",
            "required": true
          },
          {
            "name": "bucket",
            "in": "query",
            "type": "string",
            "required": false,
            "description": "The opaque destination handle returned as `bucket` from InitiateMultipartUploadResponse, passed back unchanged. Omit only for an upload opened without a destination."
          }
        ],
        "responses": {
          "200": {
            "description": "A successful response.",
            "schema": {
              "$ref": "#/definitions/SignPartResponse"
            },
            "x-example": {
              "url": "<url>",
              "headers": {
                "<key>": "<value>"
              },
              "expiresInSeconds": 1
            }
          }
        }
      }
    },
    "/upload/v1/multipart-upload/{uploadId}/complete": {
      "post": {
        "operationId": "UploadService_completeMultipartUpload",
        "summary": "Complete multipart upload",
        "tags": [
          "Upload Service"
        ],
        "description": "Completes a multipart upload to object storage. This should be called after all parts have been uploaded. Will throw EmptyMultipartUpload if there are 0 parts.",
        "parameters": [
          {
            "name": "uploadId",
            "in": "path",
            "type": "string",
            "required": true
          },
          {
            "name": "key",
            "in": "query",
            "type": "string",
            "required": true
          },
          {
            "name": "bucket",
            "in": "query",
            "type": "string",
            "required": false,
            "description": "The opaque destination handle returned as `bucket` from InitiateMultipartUploadResponse, passed back unchanged. Omit only for an upload opened without a destination."
          },
          {
            "name": "parts",
            "in": "body",
            "schema": {
              "type": "array",
              "items": {
                "$ref": "#/definitions/Part"
              },
              "example": [
                {
                  "partNumber": 1,
                  "etag": "<etag>"
                }
              ]
            },
            "required": false,
            "x-example": [
              {
                "partNumber": 1,
                "etag": "<etag>"
              }
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "A successful response.",
            "schema": {
              "$ref": "#/definitions/CompleteMultipartUploadResponse"
            },
            "x-example": {
              "location": "<location>"
            }
          }
        }
      }
    },
    "/upload/v1/multipart-upload/{uploadId}/abort": {
      "post": {
        "operationId": "UploadService_abortMultipartUpload",
        "summary": "Abort multipart upload",
        "tags": [
          "Upload Service"
        ],
        "description": "Aborts a multipart upload to S3. Frees storage used by previously uploaded parts and prevents further uploads to the same uploadId.",
        "parameters": [
          {
            "name": "uploadId",
            "in": "path",
            "type": "string",
            "required": true
          },
          {
            "name": "key",
            "in": "query",
            "type": "string",
            "required": true
          },
          {
            "name": "bucket",
            "in": "query",
            "type": "string",
            "required": false,
            "description": "The opaque destination handle returned as `bucket` from InitiateMultipartUploadResponse, passed back unchanged. Omit only for an upload opened without a destination."
          }
        ],
        "responses": {
          "204": {
            "description": "No content."
          }
        }
      }
    },
    "/upload/v1/sign-download": {
      "post": {
        "operationId": "UploadService_signDownload",
        "summary": "Sign download",
        "tags": [
          "Upload Service"
        ],
        "description": "Returns a short-lived signed GET URL for an object already in the uploads bucket. Use this to hand a freshly-uploaded object's path to an external service (e.g. Dagger) without giving that service any Nominal credentials. The caller must be authorized to upload to the workspace that owns the path (encoded as the first segment of the object key).",
        "parameters": [
          {
            "name": "request",
            "in": "body",
            "schema": {
              "$ref": "#/definitions/SignDownloadRequest"
            },
            "required": true,
            "x-example": {
              "path": "<s3 path>"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A successful response.",
            "schema": {
              "$ref": "#/definitions/SignDownloadResponse"
            },
            "x-example": {
              "url": "<url>",
              "expiresAt": "2026-01-01T00:00:00Z"
            }
          }
        }
      }
    },
    "/upload/v1/upload-file": {
      "post": {
        "operationId": "UploadService_uploadFile",
        "summary": "Upload file",
        "tags": [
          "Upload Service"
        ],
        "description": "Uploads a file to S3. Intended for smaller files.",
        "consumes": [
          "application/octet-stream"
        ],
        "parameters": [
          {
            "name": "fileName",
            "in": "query",
            "type": "string",
            "required": true
          },
          {
            "name": "sizeBytes",
            "in": "query",
            "type": "integer",
            "format": "int64",
            "required": false,
            "description": "Specifies the size in bytes of the file being uploaded. This is required to be set if the file is greater than 100MB, however in practice this should probably always be set."
          },
          {
            "name": "workspace",
            "in": "query",
            "type": "string",
            "format": "rid",
            "x-alias": "WorkspaceRid",
            "required": false,
            "description": "The workspace in which to upload the file. If not provided, the file will be uploaded to the default workspace for the user's organization, if that is configured and the user has access to it."
          },
          {
            "name": "body",
            "in": "body",
            "schema": {
              "type": "string",
              "format": "binary",
              "example": "string"
            },
            "required": true,
            "x-example": "string"
          }
        ],
        "responses": {
          "200": {
            "description": "A successful response.",
            "schema": {
              "type": "string",
              "x-alias": "S3Path"
            },
            "x-example": "<s3 path>"
          }
        }
      }
    }
  },
  "definitions": {
    "CompleteMultipartUploadResponse": {
      "type": "object",
      "properties": {
        "location": {
          "type": "string"
        }
      }
    },
    "InitiateMultipartUploadRequest": {
      "type": "object",
      "properties": {
        "filename": {
          "type": "string",
          "description": "The desired name of the file in object storage. The final name will be prefixed with a timestamp to ensure uniqueness."
        },
        "filetype": {
          "type": "string",
          "description": "The MIME type of the file, eg \"text/csv\" for CSVs or \"application/octet-stream\" for binary files."
        },
        "workspace": {
          "type": "string",
          "format": "rid",
          "x-alias": "WorkspaceRid",
          "description": "The workspace in which to upload the file. If not provided, the file will be uploaded to the default workspace for the user's organization, if that is configured and the user has access to it. Ignored if `resource` is provided."
        },
        "resource": {
          "type": "string",
          "format": "rid",
          "description": "The RID of an existing resource (e.g. a dataset) that this upload is intended to feed. When provided, the server resolves the destination workspace from the resource and authorizes the caller via the resource's permissions rather than via the workspace default-fallback. Use this in flows where the caller already knows the target resource (e.g. mesh file ingest into an existing remote dataset); this avoids the `Security:WorkspaceNotSpecified` failure for API keys whose user has no default workspace. If both `resource` and `workspace` are set, `resource` takes precedence."
        },
        "destination": {
          "allOf": [
            {
              "$ref": "#/definitions/UploadDestination"
            }
          ],
          "description": "Where the upload should be written. Defaults to UPLOADS. Whichever destination is chosen, the returned `bucket` must be passed back on the listParts/signPart/completeMultipartUpload/abortMultipartUpload calls."
        }
      },
      "required": [
        "filename",
        "filetype"
      ],
      "example": {
        "filename": "<filename>",
        "filetype": "<filetype>"
      }
    },
    "InitiateMultipartUploadResponse": {
      "type": "object",
      "properties": {
        "uploadId": {
          "type": "string"
        },
        "key": {
          "type": "string",
          "description": "Identifies the uploaded object within its destination. For a FILE_STORE upload this is the value to pass as `UploadedObjectRef.objectKey` when committing a file revision."
        },
        "bucket": {
          "type": "string",
          "description": "Opaque handle for the upload's destination. Pass it back unchanged on every subsequent call for this upload; do not parse or display it."
        }
      },
      "required": [
        "uploadId",
        "key",
        "bucket"
      ]
    },
    "Part": {
      "type": "object",
      "properties": {
        "partNumber": {
          "type": "integer",
          "format": "int32"
        },
        "etag": {
          "type": "string"
        }
      },
      "required": [
        "partNumber",
        "etag"
      ]
    },
    "PartWithSize": {
      "type": "object",
      "properties": {
        "partNumber": {
          "type": "integer",
          "format": "int32"
        },
        "etag": {
          "type": "string"
        },
        "size": {
          "type": "integer",
          "format": "int64"
        }
      },
      "required": [
        "partNumber",
        "etag",
        "size"
      ]
    },
    "SignDownloadRequest": {
      "type": "object",
      "properties": {
        "path": {
          "type": "string",
          "x-alias": "S3Path",
          "description": "Path of an object in the Nominal uploads bucket, in the form returned by CompleteMultipartUploadResponse.location. The caller must be authorized to read the workspace whose UUID prefixes the object key."
        }
      },
      "required": [
        "path"
      ],
      "example": {
        "path": "<s3 path>"
      }
    },
    "SignDownloadResponse": {
      "type": "object",
      "properties": {
        "url": {
          "type": "string",
          "description": "Short-lived signed GET URL. Expires after 15 minutes."
        },
        "expiresAt": {
          "type": "string",
          "format": "date-time",
          "description": "Absolute expiry timestamp. Callers must stop using the URL after this."
        }
      },
      "required": [
        "url",
        "expiresAt"
      ]
    },
    "SignPartResponse": {
      "type": "object",
      "properties": {
        "url": {
          "type": "string"
        },
        "headers": {
          "type": "object",
          "additionalProperties": {
            "type": "string"
          }
        },
        "expiresInSeconds": {
          "type": "integer",
          "format": "int64",
          "description": "How long the signed URL stays valid, from when it was issued. Relative rather than absolute so a client with a skewed clock still bounds the request correctly; clients should time the upload out at this point instead of letting a request run past the signature's lifetime and fail as an opaque connection reset."
        }
      },
      "required": [
        "url",
        "expiresInSeconds"
      ]
    },
    "UploadDestination": {
      "description": "The object-storage destination an upload should be written to.",
      "type": "string",
      "enum": [
        "UPLOADS",
        "FILE_STORE"
      ]
    }
  },
  "tags": [
    {
      "name": "Upload Service",
      "x-displayName": "Upload service",
      "description": "The Upload Service manages file uploads to object storage.",
      "x-errors": [
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "UploadService:EmptyMultipartUpload"
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "UploadService:IncorrectSizeBytes"
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "UploadService:NullKey"
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "UploadService:SignDownloadPathOutsideUploadsBucket"
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "UploadService:UploadBucketNotAllowed",
          "description": "A multipart-upload follow-up call specified a `bucket` this service does not recognize. It must be the value returned as `bucket` by initiateMultipartUpload, unchanged."
        },
        {
          "status": 404,
          "errorCode": "NOT_FOUND",
          "errorName": "UploadService:UploadObjectNotFound",
          "description": "A multipart-upload follow-up call named a File Store object the ledger has no row for. A malformed key and an unknown one are reported identically, so a caller cannot tell which of the two it supplied."
        },
        {
          "status": 403,
          "errorCode": "PERMISSION_DENIED",
          "errorName": "UploadService:SignDownloadUnauthorized"
        },
        {
          "status": 404,
          "errorCode": "NOT_FOUND",
          "errorName": "UploadService:ResourceNotFoundOrUnauthorized",
          "description": "The resource referenced by an upload's `resource` field was not found or the caller is not authorized to access it. Returned as NOT_FOUND (rather than PERMISSION_DENIED) to avoid leaking existence information about resources the caller cannot see."
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "UploadService:WorkspaceResourceMismatch",
          "description": "Both `workspace` and `resource` were set on the upload request, but the workspace derived from the resource does not match the explicitly-supplied workspace. Returned as a defensive sanity check so callers don't accidentally believe they're uploading to one workspace while the file is actually being routed to another."
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:RunBoundsInverted"
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:IncompatibleConnectionTypeAndLogQuery"
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:InvalidS3Path"
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:InvalidUrl"
        },
        {
          "status": 500,
          "errorCode": "INTERNAL",
          "errorName": "IngestService:FailedToDownloadFile"
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:FailedToDownloadFileUserError"
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:FrameCountTimestampsMismatch"
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:InvalidBucket"
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:InvalidCustomTimestampFormat"
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:UnsupportedIngestSource"
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:IncompatibleFileForDataset"
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:IncompatibleInputs",
          "description": "The inputs provided to the ingest job are incompatible with each other"
        },
        {
          "status": 404,
          "errorCode": "NOT_FOUND",
          "errorName": "IngestService:S3PathNotFound"
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:BatchGranularityMismatch",
          "description": "All dataset granularities must match."
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:ParallelReingestIntoDatasetV1",
          "description": "Cannot ingest multiple datasets in parallel into older datasets. It is recommended to reingest all source datasets and the target dataset into a new dataset destination."
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:MissingMetadataForReingest",
          "description": "The timestamp metadata required to reingest the dataset is missing and must be provided in the request."
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:ReingestTooManyFiles",
          "description": "The number of files to reingest is too large."
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:RecursiveReingest",
          "description": "The target dataset is included in the source datasets."
        },
        {
          "status": 500,
          "errorCode": "INTERNAL",
          "errorName": "IngestService:ContainerizedExtractorContainerFailed"
        },
        {
          "status": 404,
          "errorCode": "NOT_FOUND",
          "errorName": "IngestService:IngestJobNotFound"
        },
        {
          "status": 500,
          "errorCode": "FAILED_PRECONDITION",
          "errorName": "IngestService:IngestJobMissingDatasetForIdempotencyKey",
          "description": "A prior ingest job already exists for this idempotency key but never produced a dataset (the original submit did not get far enough). The idempotency key deduplicates to that existing job and does not re-run ingestion; rerun the job identified by ingestJobRid to complete it."
        },
        {
          "status": 404,
          "errorCode": "NOT_FOUND",
          "errorName": "IngestService:IngestTransformNotFound",
          "description": "The ingest transform does not exist, or its parent ingest job is not visible to the caller. Returned as NOT_FOUND (rather than PERMISSION_DENIED) to avoid leaking existence information about transforms the caller cannot see."
        },
        {
          "status": 409,
          "errorCode": "CONFLICT",
          "errorName": "IngestService:IngestJobNotCancellable",
          "description": "The ingest job exists but is in a terminal state (COMPLETED, FAILED, or CANCELLED) and therefore cannot be cancelled."
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:UnsupportedRequestTypeForReIngest"
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:IngestJobNotCompatibleForRerun",
          "description": "The ingest job exists but is not compatible to be re-run."
        },
        {
          "status": 404,
          "errorCode": "NOT_FOUND",
          "errorName": "IngestService:DatasetNotFound",
          "description": "The target dataset could not be found."
        },
        {
          "status": 404,
          "errorCode": "NOT_FOUND",
          "errorName": "IngestService:StreamingSessionNotFound",
          "description": "The streaming session could not be found."
        },
        {
          "status": 404,
          "errorCode": "NOT_FOUND",
          "errorName": "IngestService:DatasetFileNotFound",
          "description": "The target dataset file could not be found."
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:FileDeletionNotSupported",
          "description": "The dataset file is part of a multi-file ingest job and cannot be deleted on its own. Delete the entire ingest job instead."
        },
        {
          "status": 403,
          "errorCode": "PERMISSION_DENIED",
          "errorName": "IngestService:ContainerizedExtractorUnauthorizedPasswordAccess",
          "description": "The user is not authorized to access the containerized extractor password."
        },
        {
          "status": 400,
          "errorCode": "INVALID_ARGUMENT",
          "errorName": "IngestService:ContainerizedExtractorTimestampMetadataMissing",
          "description": "Neither the containerized extractor (or its active container image) nor the ingest request provides timestamp metadata, so the extractor output cannot be indexed. Register the image with default timestamp metadata or supply it on the request."
        }
      ]
    }
  ]
}
