본문으로 건너뛰기
Sursa Developers

큰 파일 업로드하기

파일은 설치 환경의 업로드 한도(기본 200MB)까지 POST /v1/cards 요청 하나로 보낼 수 있어요. 그보다 크거나, 오래 걸리는 요청이 끊기기 쉬운 네트워크라면 파일을 파트로 나누어 업로드하세요.

  1. 업로드를 생성하세요. 파일의 이름, 형식, 크기를 Sursa에 알려 줘요.
  2. 파트를 보내세요. 순서는 상관없고, 원하면 병렬로 보내도 돼요. 실패한 파트는 그 파트만 다시 보내요.
  3. 파일 전체의 SHA-256으로 업로드를 완료하세요. Sursa가 파일을 조립하고 확인해요.
  4. 파일 대신 업로드 ID를 사용하세요. 카드, 압축 파일, 사용자 지정 소스의 문서에 쓸 수 있어요.
한도값
파일 크기최대 5GB
파트 크기Sursa가 part_size로 정하며 5MiB~100MiB. 마지막 파트는 더 작을 수 있어요
파트 수최대 1000개
완료 기한업로드 생성 후 24시간
완료된 업로드의 사용 기한24시간

1. 업로드 생성하기

POST /v1/uploads는 파일의 이름, 형식, 크기와 업로드의 용도를 받아요.

FILE=training-video-transcripts.zip
SIZE=$(wc -c < "$FILE" | tr -d ' ')

curl -X POST https://api.sursa.com/v1/uploads \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d "{\"filename\": \"$FILE\", \"content_type\": \"application/zip\", \"size\": $SIZE, \"purpose\": \"archive\"}"
{
  "id": "upl_5d3c2b1a0f9e8d7c6b5a493827161504",
  "status": "pending",
  "part_size": 8388608,
  "part_count": 23,
  "expires_at": "2026-10-25T10:00:00Z"
}
purpose업로드의 용도
card_file카드: POST /v1/cards.
archive문서 압축 파일: POST /v1/cards/archive.
import_attachment사용자 지정 소스 문서의 파일. upload_id나 attachments에 넣어요.

키에는 업로드를 사용할 곳의 스코프가 필요해요. card_file과 archive는 cards:write, import_attachment는 import:write예요.

Sursa가 part_size를 정하고 보낼 파트 수를 알려 줘요. 파일의 SHA-256을 미리 알고 있다면 여기서 sha256 값으로 보내도 돼요.

2. 파트 보내기

파일을 part_size 바이트씩 part_count개의 조각으로 자르고(마지막 조각은 나머지), 각 조각을 PUT /v1/uploads/{upload_id}/parts/{n}으로 보내세요. 파트 번호는 1부터 시작해요. 본문은 파트의 원본 바이트예요.

UPLOAD=upl_5d3c2b1a0f9e8d7c6b5a493827161504
split -b 8388608 -a 4 "$FILE" part-

n=1
for part in part-*; do
  curl -X PUT "https://api.sursa.com/v1/uploads/$UPLOAD/parts/$n" \
    -H "Authorization: Bearer $SURSA_API_KEY" \
    -H "Content-Type: application/octet-stream" \
    --data-binary "@$part" --retry 5 --fail
  n=$((n + 1))
done

파트를 다시 보내면 교체되므로, 실패하거나 타임아웃된 파트는 그냥 다시 보내면 돼요. 빠른 연결이라면 보통 파트 4개를 병렬로 보내면 대역폭을 다 쓸 수 있어요.

파트 확인하기

모든 파트는 API를 거쳐요. 스토리지로 바로 가는 링크는 없어요. 파트를 보낸 응답에는 ETag 헤더가 있으며, 그 값은 따옴표로 감싼 파트의 SHA-256이에요. JSON 본문의 etag에도 같은 값이 담겨요. GET /v1/uploads/{upload_id}는 지금까지 받은 파트를 parts에 나열하므로, 연결이 끊긴 뒤에는 빠진 파트만 보내면 돼요.

3. 업로드 완료하기

POST /v1/uploads/{upload_id}/complete는 파일 전체의 SHA-256을 16진수 64자로 받아요. 파트도 확인하려면 parts에 각 파트의 etag를 함께 보낼 수 있어요(선택).

SHA=$(shasum -a 256 "$FILE" | cut -d' ' -f1)
curl -X POST "https://api.sursa.com/v1/uploads/$UPLOAD/complete" \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  -H "Idempotency-Key: complete-$UPLOAD" \
  -H "Content-Type: application/json" \
  -d "{\"sha256\": \"$SHA\"}"

Sursa는 파트를 조립하고 해시를 비교한 다음, 다른 업로드와 똑같이 파일 형식과 바이러스 검사(활성화된 경우)로 파일을 확인해요. 확인이 끝나면 200, 아직 진행 중이면 202로 응답해요. 업로드의 상태는 processing에서 ready로, 또는 rejected로 바뀌며 거부 이유는 reject_reason에 담겨요. 상태는 GET /v1/uploads/{upload_id}로 확인하세요.

4. 업로드 사용하기

파일을 보낼 자리에 upload_id 값을 넣으세요. 각 업로드는 한 번만 쓸 수 있어요. 사용한 뒤에는 상태가 consumed가 돼요.

카드와 압축 파일에는 multipart/form-data 폼 대신 JSON 본문에 upload_id를 담아 보내세요. 이 본문에서는 컬렉션을 collection에 이름으로 지정해요.

# A card from the uploaded file
curl -X POST https://api.sursa.com/v1/cards \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  -H "Idempotency-Key: card-from-$UPLOAD" \
  -H "Content-Type: application/json" \
  -d "{\"upload_id\": \"$UPLOAD\", \"collection\": \"support\", \"async\": true}"

# An archive of documents from the uploaded ZIP
curl -X POST https://api.sursa.com/v1/cards/archive \
  -H "Authorization: Bearer $SURSA_API_KEY" \
  -H "Idempotency-Key: archive-from-$UPLOAD" \
  -H "Content-Type: application/json" \
  -d "{\"upload_id\": \"$UPLOAD\", \"collection\": \"support\"}"

업로더 전체 예시

import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { open, stat } from "node:fs/promises";
import { basename } from "node:path";

const BASE = "https://api.sursa.com";
const auth = { Authorization: `Bearer ${process.env.SURSA_API_KEY}` };

async function call(method, path, body, headers = {}) {
  const isJson = body !== undefined && !(body instanceof Uint8Array);
  const response = await fetch(BASE + path, {
    method,
    headers: { ...auth, ...(isJson ? { "Content-Type": "application/json" } : {}), ...headers },
    body: isJson ? JSON.stringify(body) : body,
  });
  if (!response.ok) throw new Error(`${method} ${path}: HTTP ${response.status} ${await response.text()}`);
  return response.json();
}

export async function uploadLargeFile(file, contentType, purpose, parallel = 4) {
  const { size } = await stat(file);
  const upload = await call("POST", "/v1/uploads",
    { filename: basename(file), content_type: contentType, size, purpose },
    { "Idempotency-Key": crypto.randomUUID() });

  const hash = createHash("sha256");
  for await (const block of createReadStream(file)) hash.update(block);

  const handle = await open(file);

  let next = 1;
  async function worker() {
    for (let n = next++; n <= upload.part_count; n = next++) {
      const buffer = new Uint8Array(Math.min(upload.part_size, size - (n - 1) * upload.part_size));
      await handle.read(buffer, 0, buffer.length, (n - 1) * upload.part_size);
      for (let attempt = 1; ; attempt++) {
        try {
          await call("PUT", `/v1/uploads/${upload.id}/parts/${n}`, buffer, { "Content-Type": "application/octet-stream" });
          break;
        } catch (error) {
          if (attempt === 5) throw error;
          await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** attempt));
        }
      }
    }
  }
  await Promise.all(Array.from({ length: parallel }, worker));
  await handle.close();

  await call("POST", `/v1/uploads/${upload.id}/complete`, { sha256: hash.digest("hex") },
    { "Idempotency-Key": `complete-${upload.id}` });
  for (;;) {
    const state = await call("GET", `/v1/uploads/${upload.id}`);
    if (state.status === "ready") return upload.id;
    if (state.status === "rejected") throw new Error(`upload rejected: ${state.reject_reason}`);
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }
}

오류

코드상태원인
uploads.too_large413파일이 5GB보다 크거나 조직 요금제의 전체 저장 용량보다 커요.
limits.storage402조직에 남은 저장 용량이 파일을 담기에 부족해요.
uploads.part_invalid400파트 번호가 범위를 벗어났거나 파트 크기가 허용 범위를 벗어났어요.
uploads.checksum_mismatch422조립한 파일의 SHA-256이 보낸 값과 달라요. 파트가 손상되었거나 빠졌어요. 파트를 다시 보내고 다시 완료하세요.
uploads.not_ready409업로드가 아직 완료되지 않았거나 확인이 끝나지 않았어요. ready가 될 때까지 기다리세요.
uploads.already_used409이미 사용한 업로드예요. 각 업로드는 한 번만 쓸 수 있어요.
uploads.expired41024시간이 지났어요. 새 업로드를 생성하세요.
uploads.rejected422파일 형식이 이름과 맞지 않는 등 파일이 검사를 통과하지 못했어요. 이유는 메시지에 나와 있어요.
uploads.not_found404조직에 해당 업로드가 없어요.