From db6ef39550b90de48832097f1ee7dd42ca540fc4 Mon Sep 17 00:00:00 2001 From: kang-heewon Date: Sun, 21 Jun 2026 23:14:04 +0900 Subject: [PATCH] fix: validate storage provider readiness --- .changeset/storage-provider-readiness.md | 6 + .github/workflows/ci.yml | 8 +- README.md | 20 +- docs/package-catalog.json | 22 +- docs/package-docs-baseline.json | 2 - docs/package-docs-report.md | 10 +- docs/problem-code-registry.json | 228 ++++++++- .../src/tests/PublishedCli.spec.ts | 57 ++- packages/docs/astro.config.mjs | 2 + packages/docs/package.json | 2 + .../api/problems-core/src/classes/Problem.md | 36 +- .../CloudflareImagesDiagnosticsProvider.md | 60 +++ .../CloudflareImagesMissingConfigProblem.md | 298 ++++++++++++ .../src/classes/CloudflareImagesProvider.md | 334 +++++++++++++ ...loudflareImagesRetryableUpstreamProblem.md | 294 +++++++++++ ...CloudflareImagesTerminalUpstreamProblem.md | 294 +++++++++++ .../CloudflareImagesValidationProblem.md | 298 ++++++++++++ .../createCloudflareImagesResponseProblem.md | 36 ++ .../normalizeCloudflareImagesError.md | 36 ++ .../validateCloudflareImagesOptions.md | 18 + .../type-aliases/CloudflareImageDetails.md | 84 ++++ .../type-aliases/CloudflareImagesConfigKey.md | 10 + .../CloudflareImagesDiagnosticsOptions.md | 26 + .../CloudflareImagesErrorContext.md | 46 ++ .../type-aliases/CloudflareImagesOptions.md | 72 +++ .../CloudflareImagesReadinessCheckContext.md | 22 + .../CloudflareImagesReadinessCheckResult.md | 22 + .../CloudflareTransformOptions.md | 103 ++++ .../type-aliases/CloudflareUploadResponse.md | 72 +++ .../variables/CLOUDFLARE_IMAGES_OPTIONS.md | 10 + .../classes/CloudinaryDiagnosticsProvider.md | 60 +++ .../classes/CloudinaryMissingConfigProblem.md | 298 ++++++++++++ .../src/classes/CloudinaryProvider.md | 334 +++++++++++++ .../CloudinaryRetryableUpstreamProblem.md | 294 +++++++++++ .../CloudinaryTerminalUpstreamProblem.md | 294 +++++++++++ .../classes/CloudinaryValidationProblem.md | 298 ++++++++++++ .../functions/getCloudinaryErrorMessage.md | 22 + .../isRetryableCloudinaryStorageError.md | 22 + .../normalizeCloudinaryStorageError.md | 36 ++ .../src/functions/validateCloudinaryConfig.md | 18 + .../src/type-aliases/CloudinaryConfig.md | 56 +++ .../src/type-aliases/CloudinaryConfigKey.md | 10 + .../CloudinaryDiagnosticsOptions.md | 26 + .../CloudinaryReadinessCheckContext.md | 22 + .../CloudinaryReadinessCheckResult.md | 22 + .../CloudinaryStorageErrorContext.md | 46 ++ .../CloudinaryTransformOptions.md | 46 ++ .../type-aliases/CloudinaryUploadOptions.md | 58 +++ .../src/variables/CLOUDINARY_CONFIG.md | 10 + .../src/classes/BaseStorageProvider.md | 19 +- .../docs/en/reference/extension-matrix.md | 4 +- .../en/reference/problem-recovery-cookbook.md | 156 +++++- .../docs/en/reference/provider-maturity.md | 9 +- packages/docs/tsconfig.typedoc.json | 2 + packages/storage-cloudflare/README.md | 63 ++- packages/storage-cloudflare/package.json | 1 + packages/storage-cloudflare/src/index.ts | 17 + .../CloudflareImagesDiagnosticsProvider.ts | 401 +++++++++++++++ .../src/libs/CloudflareImagesProvider.ts | 155 ++++-- ...loudflareImagesDiagnosticsProvider.spec.ts | 105 ++++ .../tests/CloudflareImagesLiveSmoke.spec.ts | 85 ++++ .../tests/CloudflareImagesProvider.spec.ts | 54 ++- packages/storage-cloudflare/tsconfig.json | 6 + packages/storage-cloudflare/vitest.config.ts | 4 + packages/storage-cloudinary/README.md | 59 ++- packages/storage-cloudinary/package.json | 5 +- packages/storage-cloudinary/src/index.ts | 18 + .../src/libs/CloudinaryDiagnosticsProvider.ts | 457 ++++++++++++++++++ .../src/libs/CloudinaryProvider.ts | 262 ++++------ .../CloudinaryDiagnosticsProvider.spec.ts | 142 ++++++ .../src/tests/CloudinaryLiveSmoke.spec.ts | 63 +++ .../src/tests/CloudinaryProvider.spec.ts | 195 +++++++- packages/storage-cloudinary/tsconfig.json | 6 + packages/storage-cloudinary/vitest.config.ts | 15 + pnpm-lock.yaml | 16 + public-api-surface.snapshot.json | 152 ++++++ scripts/package-docs-check.mts | 2 +- 77 files changed, 6625 insertions(+), 348 deletions(-) create mode 100644 .changeset/storage-provider-readiness.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesDiagnosticsProvider.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesMissingConfigProblem.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesProvider.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesRetryableUpstreamProblem.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesTerminalUpstreamProblem.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesValidationProblem.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/functions/createCloudflareImagesResponseProblem.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/functions/normalizeCloudflareImagesError.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/functions/validateCloudflareImagesOptions.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImageDetails.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesConfigKey.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesDiagnosticsOptions.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesErrorContext.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesOptions.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesReadinessCheckContext.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesReadinessCheckResult.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareTransformOptions.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareUploadResponse.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudflare/src/variables/CLOUDFLARE_IMAGES_OPTIONS.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryDiagnosticsProvider.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryMissingConfigProblem.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryProvider.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryRetryableUpstreamProblem.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryTerminalUpstreamProblem.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryValidationProblem.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/functions/getCloudinaryErrorMessage.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/functions/isRetryableCloudinaryStorageError.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/functions/normalizeCloudinaryStorageError.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/functions/validateCloudinaryConfig.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryConfig.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryConfigKey.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryDiagnosticsOptions.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryReadinessCheckContext.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryReadinessCheckResult.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryStorageErrorContext.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryTransformOptions.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryUploadOptions.md create mode 100644 packages/docs/src/content/docs/api/storage-cloudinary/src/variables/CLOUDINARY_CONFIG.md create mode 100644 packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts create mode 100644 packages/storage-cloudflare/src/tests/CloudflareImagesDiagnosticsProvider.spec.ts create mode 100644 packages/storage-cloudflare/src/tests/CloudflareImagesLiveSmoke.spec.ts create mode 100644 packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts create mode 100644 packages/storage-cloudinary/src/tests/CloudinaryDiagnosticsProvider.spec.ts create mode 100644 packages/storage-cloudinary/src/tests/CloudinaryLiveSmoke.spec.ts diff --git a/.changeset/storage-provider-readiness.md b/.changeset/storage-provider-readiness.md new file mode 100644 index 000000000..790c2d6c5 --- /dev/null +++ b/.changeset/storage-provider-readiness.md @@ -0,0 +1,6 @@ +--- +"@croco/storage-cloudflare": patch +"@croco/storage-cloudinary": patch +--- + +- Expose storage provider diagnostics/readiness, deterministic upstream Problem codes, shared conformance coverage, and optional live-smoke gates for Cloudflare Images and Cloudinary. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d646906a..49074803f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -301,6 +301,8 @@ jobs: - 'packages/retry-core/src/**' - 'packages/rpc-codegen/src/**' - 'packages/search-core/src/**' + - 'packages/storage-cloudflare/src/**' + - 'packages/storage-cloudinary/src/**' - 'packages/storage-core/src/**' - 'packages/tasks-qstash/src/**' - 'packages/telemetry-api/src/**' @@ -336,8 +338,12 @@ jobs: - name: Build docs and check for drift run: | pnpm turbo run docs:build --force + mapfile -t generated_api_docs < <(git diff --name-only -- packages/docs/src/content/docs/api/) + if [ "${#generated_api_docs[@]}" -gt 0 ]; then + pnpm exec oxfmt --write "${generated_api_docs[@]}" + fi if ! git diff --exit-code -- packages/docs/src/content/docs/api/; then - echo "::error::API docs are out of sync. Run 'pnpm docs:build' locally and commit the changes." + echo "::error::API docs are out of sync. Run 'pnpm docs:build', format the changed API docs with oxfmt, then commit the changes." exit 1 fi diff --git a/README.md b/README.md index 79702b019..8bb084fa5 100644 --- a/README.md +++ b/README.md @@ -388,12 +388,12 @@ Croco가 **완전한 SaaS 프레임워크**가 되기 위해 계획 중인 기 Adapter 경계와 공식 우선순위, compatibility certification checklist는 [Adapter Ecosystem](packages/docs/src/content/docs/en/reference/adapter-ecosystem.md)에 정의되어 있습니다. 성숙도 승급 기준은 [Provider Maturity Gates](packages/docs/src/content/docs/en/reference/provider-maturity.md)와 [Presentation Runtime Support](packages/docs/src/content/docs/en/reference/presentation-runtime-support.md)에 정의되어 있으며, package test 존재 여부만으로 production-ready나 certified compatibility를 의미하지 않습니다. -| 상태 | 의미 | 패키지 수 | -| ------------------- | ----------------------------------- | --------: | -| 🟢 production-ready | 안정화, 적극 사용 권장 | 24 | -| 🟡 beta | 기능 완성, 실사용 검증 중 | 56 | -| 🔴 alpha/WIP | 개발 중, 사용 시 주의 필요 | 29 | -| ⚠️ deprecated | 대체 패키지 존재, 마이그레이션 권장 | 0 | +| 상태 | 의미 | 전체 public 패키지 수 | +| ------------------- | ----------------------------------- | --------------------: | +| 🟢 production-ready | 안정화, 적극 사용 권장 | 24 | +| 🟡 beta | 기능 완성, 실사용 검증 중 | 57 | +| 🔴 alpha/WIP | 개발 중, 사용 시 주의 필요 | 28 | +| ⚠️ deprecated | 대체 패키지 존재, 마이그레이션 권장 | 0 | ### Extension & Adapter Matrix @@ -428,8 +428,8 @@ Runtime columns: Node는 장기 실행 서버/CLI, Lambda는 서버리스 함수 | `@croco/ratelimit-upstash` | Rate limiting | Upstash Redis rate-limit store | yes | yes | - | - | UPSTASH_REDIS_REST_URL
UPSTASH_REDIS_REST_TOKEN | @upstash/redis | sliding window
token bucket
fixed window
Lua atomicity
shared conformance
redacted upstream Problems | 🔴 alpha/WIP | has package tests | | `@croco/search-drizzle` | Search | Drizzle search index | yes | yes | - | - | database connection supplied by app | drizzle-orm | search document persistence
tenant-aware lookup | 🔴 alpha/WIP | has package tests | | `@croco/search-meilisearch` | Search | Meilisearch engine | yes | yes | - | - | MEILISEARCH_HOST
MEILISEARCH_API_KEY | - | indexing
search
tenant tokens | 🔴 alpha/WIP | has package tests | -| `@croco/storage-cloudflare` | Storage | Cloudflare Images provider | yes | yes | - | - | CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_API_TOKEN
CLOUDFLARE_ACCOUNT_HASH | - | image upload
transform URLs
upload intents
signed URLs | 🔴 alpha/WIP | has package tests | -| `@croco/storage-cloudinary` | Storage | Cloudinary provider | yes | yes | - | - | CLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY
CLOUDINARY_API_SECRET | - | file upload
transform URLs
upload intents
retry | 🔴 alpha/WIP | has package tests | +| `@croco/storage-cloudflare` | Storage | Cloudflare Images provider | yes | yes | - | - | CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_API_TOKEN
CLOUDFLARE_ACCOUNT_HASH | - | image upload
transform URLs
upload intents
signed URLs
storage conformance
diagnostics
optional live smoke | 🔴 alpha/WIP | has package tests | +| `@croco/storage-cloudinary` | Storage | Cloudinary provider | yes | yes | - | - | CLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY
CLOUDINARY_API_SECRET | - | file upload
transform URLs
upload intents
retry
storage conformance
diagnostics
optional live smoke | 🟡 beta | has package tests | | `@croco/storage-r2` | Storage | Cloudflare R2 S3-compatible provider | yes | yes | - | - | R2_ACCOUNT_ID
R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY
R2_BUCKET | - | put/get/delete
signed URLs
stream reads
retry
safe diagnostics
env-gated live smoke | 🟡 beta | has package tests | | `@croco/tasks-qstash` | Tasks | QStash task runner | yes | yes | - | - | UPSTASH_QSTASH_TOKEN
UPSTASH_QSTASH_DESTINATION_URL | - | task publish
delay override
custom headers
deduplication id
shared conformance
redacted upstream Problems | 🔴 alpha/WIP | has package tests | | `@croco/triggers-qstash` | Triggers | QStash scheduler and webhook handler | yes | yes | - | - | QSTASH_TOKEN
public webhook URL | - | schedule publish
webhook verification
trigger dispatch
shared conformance
redacted schedule diagnostics
diagnostic-coded webhook failures | 🔴 alpha/WIP | has package tests | @@ -542,6 +542,7 @@ Runtime columns: Node는 장기 실행 서버/CLI, Lambda는 서버리스 함수 | `@croco/rpc-codegen` | Protocol | `packages/rpc-codegen` | README, API, tests | | `@croco/billing-polar` | Provider | `packages/billing-polar` | README, tests | | `@croco/llm-openai` | Provider | `packages/llm-openai` | README, API, tests | +| `@croco/storage-cloudinary` | Provider | `packages/storage-cloudinary` | README, API, tests | | `@croco/storage-r2` | Provider | `packages/storage-r2` | README, tests | | `@croco/architecture-policy` | Tooling | `packages/architecture-policy` | README, tests | | `@croco/cli` | Tooling | `packages/cli` | README, tests | @@ -583,8 +584,7 @@ Runtime columns: Node는 장기 실행 서버/CLI, Lambda는 서버리스 함수 | `@croco/ratelimit-upstash` | Provider | `packages/ratelimit-upstash` | README, API, tests | | `@croco/search-drizzle` | Provider | `packages/search-drizzle` | README, tests | | `@croco/search-meilisearch` | Provider | `packages/search-meilisearch` | README, tests | -| `@croco/storage-cloudflare` | Provider | `packages/storage-cloudflare` | README, tests | -| `@croco/storage-cloudinary` | Provider | `packages/storage-cloudinary` | README, tests | +| `@croco/storage-cloudflare` | Provider | `packages/storage-cloudflare` | README, API, tests | | `@croco/tasks-qstash` | Provider | `packages/tasks-qstash` | README, API, tests | | `@croco/triggers-qstash` | Provider | `packages/triggers-qstash` | README, API, tests | diff --git a/docs/package-catalog.json b/docs/package-catalog.json index 88512438b..d96685dfd 100644 --- a/docs/package-catalog.json +++ b/docs/package-catalog.json @@ -226,6 +226,7 @@ "protocols-graphql", "protocols-trpc", "rpc-codegen", + "storage-cloudinary", "storage-core", "storage-r2", "tasks-core", @@ -267,7 +268,6 @@ "search-drizzle", "search-meilisearch", "storage-cloudflare", - "storage-cloudinary", "tasks-qstash", "triggers-qstash" ] @@ -608,14 +608,30 @@ "adapter": "Cloudflare Images provider", "runtimes": ["node", "lambda"], "requiredEnv": ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_HASH"], - "features": ["image upload", "transform URLs", "upload intents", "signed URLs"] + "features": [ + "image upload", + "transform URLs", + "upload intents", + "signed URLs", + "storage conformance", + "diagnostics", + "optional live smoke" + ] }, "storage-cloudinary": { "domain": "Storage", "adapter": "Cloudinary provider", "runtimes": ["node", "lambda"], "requiredEnv": ["CLOUDINARY_CLOUD_NAME", "CLOUDINARY_API_KEY", "CLOUDINARY_API_SECRET"], - "features": ["file upload", "transform URLs", "upload intents", "retry"] + "features": [ + "file upload", + "transform URLs", + "upload intents", + "retry", + "storage conformance", + "diagnostics", + "optional live smoke" + ] }, "storage-r2": { "domain": "Storage", diff --git a/docs/package-docs-baseline.json b/docs/package-docs-baseline.json index 47dfe50cc..5e93b6a49 100644 --- a/docs/package-docs-baseline.json +++ b/docs/package-docs-baseline.json @@ -47,8 +47,6 @@ "ratelimit-upstash", "search-drizzle", "search-meilisearch", - "storage-cloudflare", - "storage-cloudinary", "storage-r2", "tasks-core", "tasks-qstash", diff --git a/docs/package-docs-report.md b/docs/package-docs-report.md index 46d283eaf..8e1c1bf43 100644 --- a/docs/package-docs-report.md +++ b/docs/package-docs-report.md @@ -9,7 +9,7 @@ | Public packages | 109 | | Private packages skipped | 2 | | Missing package README | 0 | -| Missing generated API docs | 49 | +| Missing generated API docs | 47 | | Missing package test directory | 0 | | Extension matrix packages | 41 | @@ -63,8 +63,6 @@ None. - `@croco/presentation-preset` (`packages/presentation-preset`) — legacy baseline - `@croco/search-drizzle` (`packages/search-drizzle`) — legacy baseline - `@croco/search-meilisearch` (`packages/search-meilisearch`) — legacy baseline -- `@croco/storage-cloudflare` (`packages/storage-cloudflare`) — legacy baseline -- `@croco/storage-cloudinary` (`packages/storage-cloudinary`) — legacy baseline - `@croco/storage-r2` (`packages/storage-r2`) — legacy baseline - `@croco/tasks-core` (`packages/tasks-core`) — legacy baseline - `@croco/tenant-core` (`packages/tenant-core`) — legacy baseline @@ -77,7 +75,7 @@ None. | ------------------- | ---------------: | | 🟢 production-ready | 0 | | 🟡 beta | 29 | -| 🔴 alpha/WIP | 20 | +| 🔴 alpha/WIP | 18 | | ⚠️ deprecated | 0 | ## Missing Test Directory @@ -100,8 +98,8 @@ None. | Maturity | Packages | | ------------------- | -------: | | 🟢 production-ready | 24 | -| 🟡 beta | 56 | -| 🔴 alpha/WIP | 29 | +| 🟡 beta | 57 | +| 🔴 alpha/WIP | 28 | | ⚠️ deprecated | 0 | ## Extension Matrix diff --git a/docs/problem-code-registry.json b/docs/problem-code-registry.json index eaf09da03..e022f370f 100644 --- a/docs/problem-code-registry.json +++ b/docs/problem-code-registry.json @@ -1,6 +1,6 @@ { "version": "croco.problem-code-registry.v1", - "problemCount": 367, + "problemCount": 375, "problems": [ { "code": "ACCESS_DENIED", @@ -1859,7 +1859,7 @@ "sources": [ { "file": "packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts", - "line": 44, + "line": 48, "column": 13, "kind": "problem-factory" } @@ -1886,7 +1886,7 @@ "sources": [ { "file": "packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts", - "line": 206, + "line": 256, "column": 13, "kind": "problem-factory" } @@ -1913,7 +1913,7 @@ "sources": [ { "file": "packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts", - "line": 267, + "line": 331, "column": 13, "kind": "problem-factory" } @@ -7886,6 +7886,114 @@ } ] }, + { + "code": "storage-cloudflare/missing-config", + "category": "InternalServerError", + "status": 500, + "title": "Internal Server Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#storage-cloudflare-missing-config", + "recovery": { + "cause": "Croco or an upstream dependency failed after accepting the request.", + "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.", + "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + "retryability": "conditional", + "redactionPolicy": "operator-only", + "telemetry": { + "eventName": "croco.problem.error", + "severity": "error", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts", + "line": 37, + "column": 5, + "kind": "problem-constructor" + } + ] + }, + { + "code": "storage-cloudflare/retryable-upstream", + "category": "InternalServerError", + "status": 500, + "title": "Internal Server Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#storage-cloudflare-retryable-upstream", + "recovery": { + "cause": "Croco or an upstream dependency failed after accepting the request.", + "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.", + "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + "retryability": "conditional", + "redactionPolicy": "operator-only", + "telemetry": { + "eventName": "croco.problem.error", + "severity": "error", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts", + "line": 70, + "column": 5, + "kind": "problem-constructor" + } + ] + }, + { + "code": "storage-cloudflare/terminal-upstream", + "category": "InternalServerError", + "status": 500, + "title": "Internal Server Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#storage-cloudflare-terminal-upstream", + "recovery": { + "cause": "Croco or an upstream dependency failed after accepting the request.", + "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.", + "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + "retryability": "conditional", + "redactionPolicy": "operator-only", + "telemetry": { + "eventName": "croco.problem.error", + "severity": "error", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts", + "line": 86, + "column": 5, + "kind": "problem-constructor" + } + ] + }, + { + "code": "storage-cloudflare/validation-failed", + "category": "ValidationError", + "status": 422, + "title": "Validation Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#storage-cloudflare-validation-failed", + "recovery": { + "cause": "The request or generated contract failed schema or semantic validation.", + "userAction": "Fix the invalid fields and retry with schema-conformant input.", + "operatorAction": "Inspect schema diagnostics, generated contracts, and validation metadata.", + "retryability": "not-retryable", + "redactionPolicy": "public", + "telemetry": { + "eventName": "croco.problem.info", + "severity": "info", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts", + "line": 57, + "column": 5, + "kind": "problem-constructor" + } + ] + }, { "code": "storage-cloudinary/invalid-upload-intent-ttl", "category": "BadRequest", @@ -7907,12 +8015,120 @@ "sources": [ { "file": "packages/storage-cloudinary/src/libs/CloudinaryProvider.ts", - "line": 396, + "line": 331, "column": 13, "kind": "problem-factory" } ] }, + { + "code": "storage-cloudinary/missing-config", + "category": "InternalServerError", + "status": 500, + "title": "Internal Server Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#storage-cloudinary-missing-config", + "recovery": { + "cause": "Croco or an upstream dependency failed after accepting the request.", + "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.", + "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + "retryability": "conditional", + "redactionPolicy": "operator-only", + "telemetry": { + "eventName": "croco.problem.error", + "severity": "error", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts", + "line": 51, + "column": 5, + "kind": "problem-constructor" + } + ] + }, + { + "code": "storage-cloudinary/retryable-upstream", + "category": "InternalServerError", + "status": 500, + "title": "Internal Server Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#storage-cloudinary-retryable-upstream", + "recovery": { + "cause": "Croco or an upstream dependency failed after accepting the request.", + "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.", + "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + "retryability": "conditional", + "redactionPolicy": "operator-only", + "telemetry": { + "eventName": "croco.problem.error", + "severity": "error", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts", + "line": 84, + "column": 5, + "kind": "problem-constructor" + } + ] + }, + { + "code": "storage-cloudinary/terminal-upstream", + "category": "InternalServerError", + "status": 500, + "title": "Internal Server Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#storage-cloudinary-terminal-upstream", + "recovery": { + "cause": "Croco or an upstream dependency failed after accepting the request.", + "userAction": "Retry later only when the operation is idempotent or the caller owns retry safety.", + "operatorAction": "Use traces, logs, and upstream diagnostics to isolate the failing boundary.", + "retryability": "conditional", + "redactionPolicy": "operator-only", + "telemetry": { + "eventName": "croco.problem.error", + "severity": "error", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts", + "line": 100, + "column": 5, + "kind": "problem-constructor" + } + ] + }, + { + "code": "storage-cloudinary/validation-failed", + "category": "ValidationError", + "status": 422, + "title": "Validation Error", + "cookbookPath": "/reference/problem-recovery-cookbook/#storage-cloudinary-validation-failed", + "recovery": { + "cause": "The request or generated contract failed schema or semantic validation.", + "userAction": "Fix the invalid fields and retry with schema-conformant input.", + "operatorAction": "Inspect schema diagnostics, generated contracts, and validation metadata.", + "retryability": "not-retryable", + "redactionPolicy": "public", + "telemetry": { + "eventName": "croco.problem.info", + "severity": "info", + "attributes": ["problem.code", "problem.category", "problem.status"] + } + }, + "sources": [ + { + "file": "packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts", + "line": 71, + "column": 5, + "kind": "problem-constructor" + } + ] + }, { "code": "storage/invalid-upload-intent-ttl", "category": "BadRequest", @@ -7934,7 +8150,7 @@ "sources": [ { "file": "packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts", - "line": 233, + "line": 283, "column": 13, "kind": "problem-factory" } diff --git a/packages/create-croco-app/src/tests/PublishedCli.spec.ts b/packages/create-croco-app/src/tests/PublishedCli.spec.ts index 49d61f3f3..e62fac3e0 100644 --- a/packages/create-croco-app/src/tests/PublishedCli.spec.ts +++ b/packages/create-croco-app/src/tests/PublishedCli.spec.ts @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -10,6 +10,13 @@ const __dirname = dirname(__filename); const packageDir = resolve(__dirname, "../.."); const rootDir = resolve(packageDir, "../.."); const spawnTimeoutMs = 180_000; +// These tarballs cover the packed CLI's local Croco runtime dependency graph, including transitive deps. +const packedRuntimeWorkspacePackages = ["problems-core", "diagnostics-core", "telemetry-sdk-node"]; +const requiredPackageArtifacts = [ + ...packedRuntimeWorkspacePackages.flatMap((packageName) => requiredLibraryArtifacts(packageName)), + join(packageDir, "dist", "index.js"), + join(packageDir, "dist", "index.d.ts"), +]; describe("published create-croco-app CLI", () => { it( @@ -20,21 +27,7 @@ describe("published create-croco-app CLI", () => { try { ensureBuilt(); - run( - "pnpm", - ["--filter", "@croco/problems-core", "pack", "--pack-destination", packRoot], - rootDir, - ); - run( - "pnpm", - ["--filter", "@croco/diagnostics-core", "pack", "--pack-destination", packRoot], - rootDir, - ); - run( - "pnpm", - ["--filter", "@croco/telemetry-sdk-node", "pack", "--pack-destination", packRoot], - rootDir, - ); + packRuntimeWorkspacePackages(packRoot); run( "pnpm", ["--filter", "create-croco-app", "pack", "--pack-destination", packRoot], @@ -81,17 +74,37 @@ describe("published create-croco-app CLI", () => { ); }); +function packRuntimeWorkspacePackages(packRoot: string): void { + for (const packageName of packedRuntimeWorkspacePackages) { + run( + "pnpm", + ["--filter", `@croco/${packageName}`, "pack", "--pack-destination", packRoot], + rootDir, + ); + } +} + function ensureBuilt(): void { - if ( - existsSync(join(rootDir, "packages", "problems-core", "dist", "index.js")) && - existsSync(join(rootDir, "packages", "diagnostics-core", "dist", "index.js")) && - existsSync(join(rootDir, "packages", "telemetry-sdk-node", "dist", "index.js")) && - existsSync(join(packageDir, "dist", "index.js")) - ) { + if (requiredPackageArtifacts.every((artifact) => existsSync(artifact))) { return; } run("pnpm", ["--filter", "create-croco-app...", "build"], rootDir); + + const missingArtifacts = requiredPackageArtifacts.filter((artifact) => !existsSync(artifact)); + if (missingArtifacts.length > 0) { + throw new Error( + `Missing build artifacts after build:\n${missingArtifacts.map((artifact) => `- ${artifact}`).join("\n")}`, + ); + } +} + +function requiredLibraryArtifacts(packageName: string): string[] { + const distDir = join(rootDir, "packages", packageName, "dist"); + + return ["index.js", "index.mjs", "index.d.ts", "index.d.mts"].map((filename) => + join(distDir, filename), + ); } function findTarball(directory: string, prefix: string): string { diff --git a/packages/docs/astro.config.mjs b/packages/docs/astro.config.mjs index 56264182f..26b5a8801 100644 --- a/packages/docs/astro.config.mjs +++ b/packages/docs/astro.config.mjs @@ -84,6 +84,8 @@ export default defineConfig({ "../retry-core/src/index.ts", "../rpc-codegen/src/index.ts", "../search-core/src/index.ts", + "../storage-cloudflare/src/index.ts", + "../storage-cloudinary/src/index.ts", "../storage-core/src/index.ts", "../telemetry-api/src/index.ts", "../telemetry-sdk-node/src/index.ts", diff --git a/packages/docs/package.json b/packages/docs/package.json index 529546ccd..57bc25ee1 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -70,6 +70,8 @@ "@croco/retry-core": "workspace:*", "@croco/rpc-codegen": "workspace:*", "@croco/search-core": "workspace:*", + "@croco/storage-cloudflare": "workspace:*", + "@croco/storage-cloudinary": "workspace:*", "@croco/storage-core": "workspace:*", "@croco/tasks-qstash": "workspace:*", "@croco/telemetry-api": "workspace:*", diff --git a/packages/docs/src/content/docs/api/problems-core/src/classes/Problem.md b/packages/docs/src/content/docs/api/problems-core/src/classes/Problem.md index cd58aed36..866c7a55a 100644 --- a/packages/docs/src/content/docs/api/problems-core/src/classes/Problem.md +++ b/packages/docs/src/content/docs/api/problems-core/src/classes/Problem.md @@ -177,6 +177,14 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다 - [`SearchCapabilityUnavailableProblem`](/api/search-core/src/classes/searchcapabilityunavailableproblem/) - [`StrategyUnavailableProblem`](/api/search-core/src/classes/strategyunavailableproblem/) - [`TransformNotFoundProblem`](/api/search-core/src/classes/transformnotfoundproblem/) +- [`CloudflareImagesMissingConfigProblem`](/api/storage-cloudflare/src/classes/cloudflareimagesmissingconfigproblem/) +- [`CloudflareImagesRetryableUpstreamProblem`](/api/storage-cloudflare/src/classes/cloudflareimagesretryableupstreamproblem/) +- [`CloudflareImagesTerminalUpstreamProblem`](/api/storage-cloudflare/src/classes/cloudflareimagesterminalupstreamproblem/) +- [`CloudflareImagesValidationProblem`](/api/storage-cloudflare/src/classes/cloudflareimagesvalidationproblem/) +- [`CloudinaryMissingConfigProblem`](/api/storage-cloudinary/src/classes/cloudinarymissingconfigproblem/) +- [`CloudinaryRetryableUpstreamProblem`](/api/storage-cloudinary/src/classes/cloudinaryretryableupstreamproblem/) +- [`CloudinaryTerminalUpstreamProblem`](/api/storage-cloudinary/src/classes/cloudinaryterminalupstreamproblem/) +- [`CloudinaryValidationProblem`](/api/storage-cloudinary/src/classes/cloudinaryvalidationproblem/) - [`StorageProblem`](/api/storage-core/src/classes/storageproblem/) - [`OtlpEndpointRequiredProblem`](/api/telemetry-sdk-node/src/classes/otlpendpointrequiredproblem/) - [`SamplerProblem`](/api/telemetry-sdk-node/src/classes/samplerproblem/) @@ -205,7 +213,7 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다 > `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) -*** +--- ### cause? @@ -215,31 +223,31 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다 `Error.cause` -*** +--- ### code > `readonly` **code**: `string` -*** +--- ### detail? > `readonly` `optional` **detail**: `string` -*** +--- ### extensions? > `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) -*** +--- ### instance? > `readonly` `optional` **instance**: `string` -*** +--- ### message @@ -249,7 +257,7 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다 `Error.message` -*** +--- ### name @@ -259,7 +267,7 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다 `Error.name` -*** +--- ### stack? @@ -269,13 +277,13 @@ RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다 `Error.stack` -*** +--- ### type > `readonly` **type**: `string` -*** +--- ### stackTraceLimit @@ -307,7 +315,7 @@ not capture any frames. `number` -*** +--- ### title @@ -329,7 +337,7 @@ not capture any frames. [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) -*** +--- ### captureStackTrace() @@ -342,7 +350,7 @@ a string representing the location in the code at which ```js const myObject = {}; Error.captureStackTrace(myObject); -myObject.stack; // Similar to `new Error().stack` +myObject.stack; // Similar to `new Error().stack` ``` The first line of the trace will be prefixed with @@ -397,7 +405,7 @@ a(); `Error.captureStackTrace` -*** +--- ### prepareStackTrace() diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesDiagnosticsProvider.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesDiagnosticsProvider.md new file mode 100644 index 000000000..bc7fb4d4a --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesDiagnosticsProvider.md @@ -0,0 +1,60 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudflareImagesDiagnosticsProvider" +--- + +## Implements + +- [`DiagnosticsProvider`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/) + +## Constructors + +### Constructor + +> **new CloudflareImagesDiagnosticsProvider**(`config`, `options?`): `CloudflareImagesDiagnosticsProvider` + +#### Parameters + +##### config + +`Partial`\<[`CloudflareImagesOptions`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesoptions/)\> + +##### options? + +[`CloudflareImagesDiagnosticsOptions`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesdiagnosticsoptions/) = `{}` + +#### Returns + +`CloudflareImagesDiagnosticsProvider` + +## Properties + +### name + +> `readonly` **name**: `"storage-cloudflare"` = `"storage-cloudflare"` + +#### Implementation of + +[`DiagnosticsProvider`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/).[`name`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/#name) + +## Methods + +### getHealth() + +> **getHealth**(`signal?`): `Promise`\<[`HealthStatus`](/api/diagnostics-core/src/type-aliases/healthstatus/)\> + +#### Parameters + +##### signal? + +`AbortSignal` + +#### Returns + +`Promise`\<[`HealthStatus`](/api/diagnostics-core/src/type-aliases/healthstatus/)\> + +#### Implementation of + +[`DiagnosticsProvider`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/).[`getHealth`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/#gethealth) diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesMissingConfigProblem.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesMissingConfigProblem.md new file mode 100644 index 000000000..93193a88e --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesMissingConfigProblem.md @@ -0,0 +1,298 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudflareImagesMissingConfigProblem" +--- + +Problem raised when Cloudflare Images storage is used without a required configuration value. + +## Extends + +- [`Problem`](/api/problems-core/src/classes/problem/) + +## Constructors + +### Constructor + +> **new CloudflareImagesMissingConfigProblem**(`configKey`, `operation?`): `CloudflareImagesMissingConfigProblem` + +#### Parameters + +##### configKey + +[`CloudflareImagesConfigKey`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesconfigkey/) + +##### operation? + +`string` = `"configuration"` + +#### Returns + +`CloudflareImagesMissingConfigProblem` + +#### Overrides + +`Problem.constructor` + +## Properties + +### category + +> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) + +--- + +### cause? + +> `readonly` `optional` **cause**: `Error` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause) + +--- + +### code + +> `readonly` **code**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) + +--- + +### detail? + +> `readonly` `optional` **detail**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail) + +--- + +### extensions? + +> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions) + +--- + +### instance? + +> `readonly` `optional` **instance**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance) + +--- + +### message + +> **message**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message) + +--- + +### name + +> **name**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name) + +--- + +### stack? + +> `optional` **stack**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack) + +--- + +### type + +> `readonly` **type**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type) + +--- + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit) + +## Accessors + +### status + +#### Get Signature + +> **get** **status**(): `number` + +##### Returns + +`number` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status) + +--- + +### title + +#### Get Signature + +> **get** **title**(): `string` + +##### Returns + +`string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title) + +## Methods + +### toJSON() + +> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Returns + +[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson) + +--- + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace) + +--- + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace) diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesProvider.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesProvider.md new file mode 100644 index 000000000..110a71826 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesProvider.md @@ -0,0 +1,334 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudflareImagesProvider" +--- + +Cloudflare Images를 이용해 파일 저장과 이미지 변환 URL 생성을 제공하는 구현체입니다. + +## Extends + +- [`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/) + +## Implements + +- [`ImageProvider`](/api/storage-core/src/type-aliases/imageprovider/) + +## Constructors + +### Constructor + +> **new CloudflareImagesProvider**(`options`): `CloudflareImagesProvider` + +#### Parameters + +##### options + +[`CloudflareImagesOptions`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesoptions/) + +#### Returns + +`CloudflareImagesProvider` + +#### Overrides + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`constructor`](/api/storage-core/src/classes/basestorageprovider/#constructor) + +## Methods + +### delete() + +> **delete**(`key`): `Promise`\<`void`\> + +파일 삭제 + +#### Parameters + +##### key + +`string` + +파일 식별자 + +#### Returns + +`Promise`\<`void`\> + +#### Overrides + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`delete`](/api/storage-core/src/classes/basestorageprovider/#delete) + +--- + +### exists() + +> **exists**(`key`): `Promise`\<`boolean`\> + +파일 존재 여부 확인 + +#### Parameters + +##### key + +`string` + +파일 식별자 + +#### Returns + +`Promise`\<`boolean`\> + +#### Inherited from + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`exists`](/api/storage-core/src/classes/basestorageprovider/#exists) + +--- + +### get() + +> **get**(`key`): `Promise`\<`Buffer`\<`ArrayBufferLike`\>\> + +파일 다운로드 + +#### Parameters + +##### key + +`string` + +파일 식별자 + +#### Returns + +`Promise`\<`Buffer`\<`ArrayBufferLike`\>\> + +파일 버퍼 + +#### Throws + +FileNotFoundProblem - 파일이 존재하지 않을 때 + +#### Overrides + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`get`](/api/storage-core/src/classes/basestorageprovider/#get) + +--- + +### getMetadata() + +> **getMetadata**(`key`): `Promise`\<\{ `contentType?`: `string`; `etag?`: `string`; `lastModified`: `Date`; `size`: `number`; \}\> + +객체 메타데이터 조회 + +#### Parameters + +##### key + +`string` + +파일 식별자 + +#### Returns + +`Promise`\<\{ `contentType?`: `string`; `etag?`: `string`; `lastModified`: `Date`; `size`: `number`; \}\> + +객체 메타데이터 + +#### Throws + +FileNotFoundProblem - 파일이 존재하지 않을 때 + +#### Overrides + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`getMetadata`](/api/storage-core/src/classes/basestorageprovider/#getmetadata) + +--- + +### getPublicUrl() + +> **getPublicUrl**(`key`): `string` + +공개 URL 반환 + +#### Parameters + +##### key + +`string` + +파일 식별자 + +#### Returns + +`string` + +공개 액세스 가능한 URL + +#### Overrides + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`getPublicUrl`](/api/storage-core/src/classes/basestorageprovider/#getpublicurl) + +--- + +### getSignedUrl() + +> **getSignedUrl**(`key`, `options`): `Promise`\<`string`\> + +서명된 URL 반환 (임시 액세스) + +#### Parameters + +##### key + +`string` + +파일 식별자 + +##### options + +[`SignedUrlOptions`](/api/storage-core/src/type-aliases/signedurloptions/) + +만료 시간 등 옵션 + +#### Returns + +`Promise`\<`string`\> + +서명된 URL + +#### Overrides + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`getSignedUrl`](/api/storage-core/src/classes/basestorageprovider/#getsignedurl) + +--- + +### getStream() + +> **getStream**(`key`): `Promise`\<`Readable`\> + +파일 스트림 다운로드 + +#### Parameters + +##### key + +`string` + +파일 식별자 + +#### Returns + +`Promise`\<`Readable`\> + +읽기 가능한 스트림 + +#### Throws + +FileNotFoundProblem - 파일이 존재하지 않을 때 + +#### Inherited from + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`getStream`](/api/storage-core/src/classes/basestorageprovider/#getstream) + +--- + +### getTransformUrl() + +> **getTransformUrl**(`key`, `options`): `string` + +변환된 이미지 URL 반환 + +CDN에서 실시간으로 이미지를 변환하고 반환합니다. + +#### Parameters + +##### key + +`string` + +원본 이미지 식별자 + +##### options + +[`TransformOptions`](/api/storage-core/src/type-aliases/transformoptions/) + +변환 옵션 + +#### Returns + +`string` + +변환된 이미지의 공개 URL + +#### Implementation of + +`ImageProvider.getTransformUrl` + +--- + +### getUploadIntent() + +> **getUploadIntent**(`key`, `options?`): `Promise`\<[`UploadIntent`](/api/storage-core/src/type-aliases/uploadintent/)\> + +클라이언트 직접 업로드를 위한 의도 생성 (선택) + +#### Parameters + +##### key + +`string` + +업로드할 파일 식별자 + +##### options? + +###### ttlInSeconds? + +`number` + +#### Returns + +`Promise`\<[`UploadIntent`](/api/storage-core/src/type-aliases/uploadintent/)\> + +업로드 의도 정보 + +#### Implementation of + +`ImageProvider.getUploadIntent` + +--- + +### put() + +> **put**(`key`, `data`, `options?`): `Promise`\<`void`\> + +파일 업로드 + +#### Parameters + +##### key + +`string` + +파일 식별자 + +##### data + +파일 데이터 (Buffer 또는 Readable 스트림) + +`Readable` | `Buffer`\<`ArrayBufferLike`\> + +##### options? + +[`PutOptions`](/api/storage-core/src/type-aliases/putoptions/) + +업로드 옵션 + +#### Returns + +`Promise`\<`void`\> + +#### Overrides + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`put`](/api/storage-core/src/classes/basestorageprovider/#put) diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesRetryableUpstreamProblem.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesRetryableUpstreamProblem.md new file mode 100644 index 000000000..149da106f --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesRetryableUpstreamProblem.md @@ -0,0 +1,294 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudflareImagesRetryableUpstreamProblem" +--- + +RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다. + +## Extends + +- [`Problem`](/api/problems-core/src/classes/problem/) + +## Constructors + +### Constructor + +> **new CloudflareImagesRetryableUpstreamProblem**(`context`): `CloudflareImagesRetryableUpstreamProblem` + +#### Parameters + +##### context + +[`CloudflareImagesErrorContext`](/api/storage-cloudflare/src/type-aliases/cloudflareimageserrorcontext/) + +#### Returns + +`CloudflareImagesRetryableUpstreamProblem` + +#### Overrides + +`Problem.constructor` + +## Properties + +### category + +> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) + +--- + +### cause? + +> `readonly` `optional` **cause**: `Error` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause) + +--- + +### code + +> `readonly` **code**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) + +--- + +### detail? + +> `readonly` `optional` **detail**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail) + +--- + +### extensions? + +> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions) + +--- + +### instance? + +> `readonly` `optional` **instance**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance) + +--- + +### message + +> **message**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message) + +--- + +### name + +> **name**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name) + +--- + +### stack? + +> `optional` **stack**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack) + +--- + +### type + +> `readonly` **type**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type) + +--- + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit) + +## Accessors + +### status + +#### Get Signature + +> **get** **status**(): `number` + +##### Returns + +`number` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status) + +--- + +### title + +#### Get Signature + +> **get** **title**(): `string` + +##### Returns + +`string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title) + +## Methods + +### toJSON() + +> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Returns + +[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson) + +--- + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace) + +--- + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace) diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesTerminalUpstreamProblem.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesTerminalUpstreamProblem.md new file mode 100644 index 000000000..75e9d1345 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesTerminalUpstreamProblem.md @@ -0,0 +1,294 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudflareImagesTerminalUpstreamProblem" +--- + +RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다. + +## Extends + +- [`Problem`](/api/problems-core/src/classes/problem/) + +## Constructors + +### Constructor + +> **new CloudflareImagesTerminalUpstreamProblem**(`context`): `CloudflareImagesTerminalUpstreamProblem` + +#### Parameters + +##### context + +[`CloudflareImagesErrorContext`](/api/storage-cloudflare/src/type-aliases/cloudflareimageserrorcontext/) + +#### Returns + +`CloudflareImagesTerminalUpstreamProblem` + +#### Overrides + +`Problem.constructor` + +## Properties + +### category + +> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) + +--- + +### cause? + +> `readonly` `optional` **cause**: `Error` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause) + +--- + +### code + +> `readonly` **code**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) + +--- + +### detail? + +> `readonly` `optional` **detail**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail) + +--- + +### extensions? + +> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions) + +--- + +### instance? + +> `readonly` `optional` **instance**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance) + +--- + +### message + +> **message**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message) + +--- + +### name + +> **name**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name) + +--- + +### stack? + +> `optional` **stack**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack) + +--- + +### type + +> `readonly` **type**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type) + +--- + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit) + +## Accessors + +### status + +#### Get Signature + +> **get** **status**(): `number` + +##### Returns + +`number` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status) + +--- + +### title + +#### Get Signature + +> **get** **title**(): `string` + +##### Returns + +`string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title) + +## Methods + +### toJSON() + +> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Returns + +[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson) + +--- + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace) + +--- + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace) diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesValidationProblem.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesValidationProblem.md new file mode 100644 index 000000000..ffe9863d8 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/classes/CloudflareImagesValidationProblem.md @@ -0,0 +1,298 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudflareImagesValidationProblem" +--- + +RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다. + +## Extends + +- [`Problem`](/api/problems-core/src/classes/problem/) + +## Constructors + +### Constructor + +> **new CloudflareImagesValidationProblem**(`context`, `detail?`): `CloudflareImagesValidationProblem` + +#### Parameters + +##### context + +[`CloudflareImagesErrorContext`](/api/storage-cloudflare/src/type-aliases/cloudflareimageserrorcontext/) + +##### detail? + +`string` = `"Cloudflare Images request validation failed"` + +#### Returns + +`CloudflareImagesValidationProblem` + +#### Overrides + +`Problem.constructor` + +## Properties + +### category + +> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) + +--- + +### cause? + +> `readonly` `optional` **cause**: `Error` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause) + +--- + +### code + +> `readonly` **code**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) + +--- + +### detail? + +> `readonly` `optional` **detail**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail) + +--- + +### extensions? + +> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions) + +--- + +### instance? + +> `readonly` `optional` **instance**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance) + +--- + +### message + +> **message**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message) + +--- + +### name + +> **name**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name) + +--- + +### stack? + +> `optional` **stack**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack) + +--- + +### type + +> `readonly` **type**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type) + +--- + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit) + +## Accessors + +### status + +#### Get Signature + +> **get** **status**(): `number` + +##### Returns + +`number` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status) + +--- + +### title + +#### Get Signature + +> **get** **title**(): `string` + +##### Returns + +`string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title) + +## Methods + +### toJSON() + +> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Returns + +[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson) + +--- + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace) + +--- + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace) diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/createCloudflareImagesResponseProblem.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/createCloudflareImagesResponseProblem.md new file mode 100644 index 000000000..2979b5055 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/createCloudflareImagesResponseProblem.md @@ -0,0 +1,36 @@ +--- +editUrl: false +next: false +prev: false +title: "createCloudflareImagesResponseProblem" +--- + +> **createCloudflareImagesResponseProblem**(`options`): [`Problem`](/api/problems-core/src/classes/problem/) + +## Parameters + +### options + +#### detail? + +`string` + +#### key? + +`string` + +#### operation + +`string` + +#### status? + +`number` + +#### upstreamCode? + +`string` + +## Returns + +[`Problem`](/api/problems-core/src/classes/problem/) diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/normalizeCloudflareImagesError.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/normalizeCloudflareImagesError.md new file mode 100644 index 000000000..fdc09b0cc --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/normalizeCloudflareImagesError.md @@ -0,0 +1,36 @@ +--- +editUrl: false +next: false +prev: false +title: "normalizeCloudflareImagesError" +--- + +> **normalizeCloudflareImagesError**(`error`, `options`): [`Problem`](/api/problems-core/src/classes/problem/) + +## Parameters + +### error + +`unknown` + +### options + +#### key? + +`string` + +#### operation + +`string` + +#### status? + +`number` + +#### upstreamCode? + +`string` + +## Returns + +[`Problem`](/api/problems-core/src/classes/problem/) diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/validateCloudflareImagesOptions.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/validateCloudflareImagesOptions.md new file mode 100644 index 000000000..b919588c3 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/functions/validateCloudflareImagesOptions.md @@ -0,0 +1,18 @@ +--- +editUrl: false +next: false +prev: false +title: "validateCloudflareImagesOptions" +--- + +> **validateCloudflareImagesOptions**(`config`): [`CloudflareImagesOptions`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesoptions/) + +## Parameters + +### config + +`Partial`\<[`CloudflareImagesOptions`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesoptions/)\> + +## Returns + +[`CloudflareImagesOptions`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesoptions/) diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImageDetails.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImageDetails.md new file mode 100644 index 000000000..251bdfa81 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImageDetails.md @@ -0,0 +1,84 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudflareImageDetails" +--- + +> **CloudflareImageDetails** = `object` + +Cloudflare Images 상세 조회 응답 구조입니다. + +## Properties + +### errors + +> **errors**: `unknown`[] + +에러 목록 + +--- + +### messages + +> **messages**: `unknown`[] + +메시지 목록 + +--- + +### result + +> **result**: \{ `filename`: `string`; `id`: `string`; `requireSignedURLs`: `boolean`; `size?`: `number`; `uploaded`: `string`; `variants`: `string`[]; \} \| `null` + +이미지 상세 정보 + +#### Type Declaration + +\{ `filename`: `string`; `id`: `string`; `requireSignedURLs`: `boolean`; `size?`: `number`; `uploaded`: `string`; `variants`: `string`[]; \} + +#### filename + +> **filename**: `string` + +원본 파일명 + +#### id + +> **id**: `string` + +이미지 ID + +#### requireSignedURLs + +> **requireSignedURLs**: `boolean` + +서명된 URL 필요 여부 + +#### size? + +> `optional` **size**: `number` + +이미지 크기 (bytes) + +#### uploaded + +> **uploaded**: `string` + +업로드 시간 + +#### variants + +> **variants**: `string`[] + +가능한 변성(variants) URL 목록 + +`null` + +--- + +### success + +> **success**: `boolean` + +성공 여부 diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesConfigKey.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesConfigKey.md new file mode 100644 index 000000000..9c00d2450 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesConfigKey.md @@ -0,0 +1,10 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudflareImagesConfigKey" +--- + +> **CloudflareImagesConfigKey** = `"accountHash"` \| `"accountId"` \| `"apiToken"` + +Cloudflare Images 제공자 구성과 API 응답에 필요한 공개 타입들입니다. diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesDiagnosticsOptions.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesDiagnosticsOptions.md new file mode 100644 index 000000000..8cb1561e2 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesDiagnosticsOptions.md @@ -0,0 +1,26 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudflareImagesDiagnosticsOptions" +--- + +> **CloudflareImagesDiagnosticsOptions** = `object` + +Cloudflare Images 제공자 구성과 API 응답에 필요한 공개 타입들입니다. + +## Properties + +### readinessCheck()? + +> `readonly` `optional` **readinessCheck**: (`context`) => `Promise`\<[`CloudflareImagesReadinessCheckResult`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesreadinesscheckresult/) \| `void`\> + +#### Parameters + +##### context + +[`CloudflareImagesReadinessCheckContext`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesreadinesscheckcontext/) + +#### Returns + +`Promise`\<[`CloudflareImagesReadinessCheckResult`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesreadinesscheckresult/) \| `void`\> diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesErrorContext.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesErrorContext.md new file mode 100644 index 000000000..7f943f7e6 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesErrorContext.md @@ -0,0 +1,46 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudflareImagesErrorContext" +--- + +> **CloudflareImagesErrorContext** = `object` + +Cloudflare Images 제공자 구성과 API 응답에 필요한 공개 타입들입니다. + +## Properties + +### key? + +> `readonly` `optional` **key**: `string` + +--- + +### operation + +> `readonly` **operation**: `string` + +--- + +### provider + +> `readonly` **provider**: `"cloudflare-images"` + +--- + +### retryable? + +> `readonly` `optional` **retryable**: `boolean` + +--- + +### status? + +> `readonly` `optional` **status**: `number` + +--- + +### upstreamCode? + +> `readonly` `optional` **upstreamCode**: `string` diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesOptions.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesOptions.md new file mode 100644 index 000000000..3c079c8f0 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesOptions.md @@ -0,0 +1,72 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudflareImagesOptions" +--- + +> **CloudflareImagesOptions** = `object` + +Cloudflare Images 제공자 설정입니다. + +## Properties + +### accountHash + +> **accountHash**: `string` + +Cloudflare Account Hash (공개 URL용) + +--- + +### accountId + +> **accountId**: `string` + +Cloudflare Account ID + +--- + +### apiToken + +> **apiToken**: `string` + +Cloudflare API Token (Images API 권한 필요) + +--- + +### customDomain? + +> `optional` **customDomain**: `string` + +커스텀 도메인 (선택) +설정된 경우 커스텀 도메인을 통해 이미지 제공 + +--- + +### defaultVariant? + +> `optional` **defaultVariant**: `string` + +기본 변형 (variant) +기본값: 'public' + +--- + +### maxUploadBytes? + +> `optional` **maxUploadBytes**: `number` + +--- + +### signingKey? + +> `optional` **signingKey**: `string` + +--- + +### ttl? + +> `optional` **ttl**: `number` + +Upload Intent TTL (초 단위, 기본값: 3600) diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesReadinessCheckContext.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesReadinessCheckContext.md new file mode 100644 index 000000000..479845601 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesReadinessCheckContext.md @@ -0,0 +1,22 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudflareImagesReadinessCheckContext" +--- + +> **CloudflareImagesReadinessCheckContext** = `object` + +Cloudflare Images 제공자 구성과 API 응답에 필요한 공개 타입들입니다. + +## Properties + +### config + +> `readonly` **config**: [`CloudflareImagesOptions`](/api/storage-cloudflare/src/type-aliases/cloudflareimagesoptions/) + +--- + +### signal? + +> `readonly` `optional` **signal**: `AbortSignal` diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesReadinessCheckResult.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesReadinessCheckResult.md new file mode 100644 index 000000000..091614a82 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareImagesReadinessCheckResult.md @@ -0,0 +1,22 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudflareImagesReadinessCheckResult" +--- + +> **CloudflareImagesReadinessCheckResult** = `object` + +Cloudflare Images 제공자 구성과 API 응답에 필요한 공개 타입들입니다. + +## Properties + +### details? + +> `readonly` `optional` **details**: `Record`\<`string`, `unknown`\> + +--- + +### message? + +> `readonly` `optional` **message**: `string` diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareTransformOptions.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareTransformOptions.md new file mode 100644 index 000000000..293266793 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareTransformOptions.md @@ -0,0 +1,103 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudflareTransformOptions" +--- + +> **CloudflareTransformOptions** = `object` + +Cloudflare 고유 변환 옵션입니다. + +## Properties + +### blur? + +> `optional` **blur**: `number` + +블러 (1-1000) + +--- + +### dpr? + +> `optional` **dpr**: `number` + +Device Pixel Ratio + +--- + +### fit? + +> `optional` **fit**: `"scale-down"` \| `"contain"` \| `"cover"` \| `"fill"` + +맞춤 방식 + +- scale-down: 비율 유지하며 지정 크기 내에서 축소 +- contain: 비율 유지하며 지정 크기에 맞춤 (여백 있음) +- cover: 비율 유지하며 지정 크기 채움 (자름) +- fill: 비율 무시하고 지정 크기 채움 + +--- + +### format? + +> `optional` **format**: `"webp"` \| `"avif"` \| `"jpeg"` \| `"png"` \| `"gif"` + +출력 형식 + +--- + +### grayscale? + +> `optional` **grayscale**: `boolean` + +그레이스케일 변환 + +--- + +### height? + +> `optional` **height**: `number` + +높이 (px) + +--- + +### invert? + +> `optional` **invert**: `boolean` + +반전 + +--- + +### quality? + +> `optional` **quality**: `number` + +품질 (1-100) + +--- + +### rotate? + +> `optional` **rotate**: `number` + +회전 (0-359) + +--- + +### sharpen? + +> `optional` **sharpen**: `number` + +선명화 (1-10) + +--- + +### width? + +> `optional` **width**: `number` + +너비 (px) diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareUploadResponse.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareUploadResponse.md new file mode 100644 index 000000000..6c9de4a7f --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/type-aliases/CloudflareUploadResponse.md @@ -0,0 +1,72 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudflareUploadResponse" +--- + +> **CloudflareUploadResponse** = `object` + +Cloudflare Images 업로드 응답 구조입니다. + +## Properties + +### errors + +> **errors**: `unknown`[] + +에러 목록 + +--- + +### messages + +> **messages**: `unknown`[] + +메시지 목록 + +--- + +### result + +> **result**: `object` + +업로드 결과 + +#### filename + +> **filename**: `string` + +원본 파일명 + +#### id + +> **id**: `string` + +이미지 ID (고유 식별자) + +#### requireSignedURLs + +> **requireSignedURLs**: `boolean` + +서명된 URL 필요 여부 + +#### uploaded + +> **uploaded**: `string` + +업로드 시간 + +#### variants + +> **variants**: `string`[] + +가능한 변형(variants) URL 목록 + +--- + +### success + +> **success**: `boolean` + +성공 여부 diff --git a/packages/docs/src/content/docs/api/storage-cloudflare/src/variables/CLOUDFLARE_IMAGES_OPTIONS.md b/packages/docs/src/content/docs/api/storage-cloudflare/src/variables/CLOUDFLARE_IMAGES_OPTIONS.md new file mode 100644 index 000000000..c7786e444 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudflare/src/variables/CLOUDFLARE_IMAGES_OPTIONS.md @@ -0,0 +1,10 @@ +--- +editUrl: false +next: false +prev: false +title: "CLOUDFLARE_IMAGES_OPTIONS" +--- + +> `const` **CLOUDFLARE_IMAGES_OPTIONS**: _typeof_ `CLOUDFLARE_IMAGES_OPTIONS` + +Cloudflare Images 제공자 설정 DI 토큰 diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryDiagnosticsProvider.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryDiagnosticsProvider.md new file mode 100644 index 000000000..0966f8cf6 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryDiagnosticsProvider.md @@ -0,0 +1,60 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudinaryDiagnosticsProvider" +--- + +## Implements + +- [`DiagnosticsProvider`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/) + +## Constructors + +### Constructor + +> **new CloudinaryDiagnosticsProvider**(`config`, `options?`): `CloudinaryDiagnosticsProvider` + +#### Parameters + +##### config + +`Partial`\<[`CloudinaryConfig`](/api/storage-cloudinary/src/type-aliases/cloudinaryconfig/)\> + +##### options? + +[`CloudinaryDiagnosticsOptions`](/api/storage-cloudinary/src/type-aliases/cloudinarydiagnosticsoptions/) = `{}` + +#### Returns + +`CloudinaryDiagnosticsProvider` + +## Properties + +### name + +> `readonly` **name**: `"storage-cloudinary"` = `"storage-cloudinary"` + +#### Implementation of + +[`DiagnosticsProvider`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/).[`name`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/#name) + +## Methods + +### getHealth() + +> **getHealth**(`signal?`): `Promise`\<[`HealthStatus`](/api/diagnostics-core/src/type-aliases/healthstatus/)\> + +#### Parameters + +##### signal? + +`AbortSignal` + +#### Returns + +`Promise`\<[`HealthStatus`](/api/diagnostics-core/src/type-aliases/healthstatus/)\> + +#### Implementation of + +[`DiagnosticsProvider`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/).[`getHealth`](/api/diagnostics-core/src/interfaces/diagnosticsprovider/#gethealth) diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryMissingConfigProblem.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryMissingConfigProblem.md new file mode 100644 index 000000000..3292a6b78 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryMissingConfigProblem.md @@ -0,0 +1,298 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudinaryMissingConfigProblem" +--- + +RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다. + +## Extends + +- [`Problem`](/api/problems-core/src/classes/problem/) + +## Constructors + +### Constructor + +> **new CloudinaryMissingConfigProblem**(`configKey`, `operation?`): `CloudinaryMissingConfigProblem` + +#### Parameters + +##### configKey + +[`CloudinaryConfigKey`](/api/storage-cloudinary/src/type-aliases/cloudinaryconfigkey/) + +##### operation? + +`string` = `"configuration"` + +#### Returns + +`CloudinaryMissingConfigProblem` + +#### Overrides + +`Problem.constructor` + +## Properties + +### category + +> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) + +--- + +### cause? + +> `readonly` `optional` **cause**: `Error` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause) + +--- + +### code + +> `readonly` **code**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) + +--- + +### detail? + +> `readonly` `optional` **detail**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail) + +--- + +### extensions? + +> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions) + +--- + +### instance? + +> `readonly` `optional` **instance**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance) + +--- + +### message + +> **message**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message) + +--- + +### name + +> **name**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name) + +--- + +### stack? + +> `optional` **stack**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack) + +--- + +### type + +> `readonly` **type**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type) + +--- + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit) + +## Accessors + +### status + +#### Get Signature + +> **get** **status**(): `number` + +##### Returns + +`number` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status) + +--- + +### title + +#### Get Signature + +> **get** **title**(): `string` + +##### Returns + +`string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title) + +## Methods + +### toJSON() + +> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Returns + +[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson) + +--- + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace) + +--- + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace) diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryProvider.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryProvider.md new file mode 100644 index 000000000..536bfbf16 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryProvider.md @@ -0,0 +1,334 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudinaryProvider" +--- + +Cloudinary를 이용해 파일 저장과 이미지 변환 URL 생성을 제공하는 구현체입니다. + +## Extends + +- [`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/) + +## Implements + +- [`ImageProvider`](/api/storage-core/src/type-aliases/imageprovider/) + +## Constructors + +### Constructor + +> **new CloudinaryProvider**(`config`): `CloudinaryProvider` + +#### Parameters + +##### config + +[`CloudinaryConfig`](/api/storage-cloudinary/src/type-aliases/cloudinaryconfig/) + +#### Returns + +`CloudinaryProvider` + +#### Overrides + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`constructor`](/api/storage-core/src/classes/basestorageprovider/#constructor) + +## Methods + +### delete() + +> **delete**(`key`): `Promise`\<`void`\> + +파일 삭제 + +#### Parameters + +##### key + +`string` + +파일 식별자 + +#### Returns + +`Promise`\<`void`\> + +#### Overrides + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`delete`](/api/storage-core/src/classes/basestorageprovider/#delete) + +--- + +### exists() + +> **exists**(`key`): `Promise`\<`boolean`\> + +파일 존재 여부 확인 + +#### Parameters + +##### key + +`string` + +파일 식별자 + +#### Returns + +`Promise`\<`boolean`\> + +#### Inherited from + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`exists`](/api/storage-core/src/classes/basestorageprovider/#exists) + +--- + +### get() + +> **get**(`key`): `Promise`\<`Buffer`\<`ArrayBufferLike`\>\> + +파일 다운로드 + +#### Parameters + +##### key + +`string` + +파일 식별자 + +#### Returns + +`Promise`\<`Buffer`\<`ArrayBufferLike`\>\> + +파일 버퍼 + +#### Throws + +FileNotFoundProblem - 파일이 존재하지 않을 때 + +#### Overrides + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`get`](/api/storage-core/src/classes/basestorageprovider/#get) + +--- + +### getMetadata() + +> **getMetadata**(`key`): `Promise`\<[`ObjectMetadata`](/api/storage-core/src/type-aliases/objectmetadata/)\> + +객체 메타데이터 조회 + +#### Parameters + +##### key + +`string` + +파일 식별자 + +#### Returns + +`Promise`\<[`ObjectMetadata`](/api/storage-core/src/type-aliases/objectmetadata/)\> + +객체 메타데이터 + +#### Throws + +FileNotFoundProblem - 파일이 존재하지 않을 때 + +#### Overrides + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`getMetadata`](/api/storage-core/src/classes/basestorageprovider/#getmetadata) + +--- + +### getPublicUrl() + +> **getPublicUrl**(`key`): `string` + +공개 URL 반환 + +#### Parameters + +##### key + +`string` + +파일 식별자 + +#### Returns + +`string` + +공개 액세스 가능한 URL + +#### Overrides + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`getPublicUrl`](/api/storage-core/src/classes/basestorageprovider/#getpublicurl) + +--- + +### getSignedUrl() + +> **getSignedUrl**(`key`, `options`): `Promise`\<`string`\> + +서명된 URL 반환 (임시 액세스) + +#### Parameters + +##### key + +`string` + +파일 식별자 + +##### options + +[`SignedUrlOptions`](/api/storage-core/src/type-aliases/signedurloptions/) + +만료 시간 등 옵션 + +#### Returns + +`Promise`\<`string`\> + +서명된 URL + +#### Overrides + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`getSignedUrl`](/api/storage-core/src/classes/basestorageprovider/#getsignedurl) + +--- + +### getStream() + +> **getStream**(`key`): `Promise`\<`Readable`\> + +파일 스트림 다운로드 + +#### Parameters + +##### key + +`string` + +파일 식별자 + +#### Returns + +`Promise`\<`Readable`\> + +읽기 가능한 스트림 + +#### Throws + +FileNotFoundProblem - 파일이 존재하지 않을 때 + +#### Inherited from + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`getStream`](/api/storage-core/src/classes/basestorageprovider/#getstream) + +--- + +### getTransformUrl() + +> **getTransformUrl**(`key`, `options`): `string` + +변환된 이미지 URL 반환 + +CDN에서 실시간으로 이미지를 변환하고 반환합니다. + +#### Parameters + +##### key + +`string` + +원본 이미지 식별자 + +##### options + +[`TransformOptions`](/api/storage-core/src/type-aliases/transformoptions/) + +변환 옵션 + +#### Returns + +`string` + +변환된 이미지의 공개 URL + +#### Implementation of + +`ImageProvider.getTransformUrl` + +--- + +### getUploadIntent() + +> **getUploadIntent**(`key`, `options?`): `Promise`\<[`UploadIntent`](/api/storage-core/src/type-aliases/uploadintent/)\> + +클라이언트 직접 업로드를 위한 의도 생성 (선택) + +#### Parameters + +##### key + +`string` + +업로드할 파일 식별자 + +##### options? + +###### ttlInSeconds? + +`number` + +#### Returns + +`Promise`\<[`UploadIntent`](/api/storage-core/src/type-aliases/uploadintent/)\> + +업로드 의도 정보 + +#### Implementation of + +`ImageProvider.getUploadIntent` + +--- + +### put() + +> **put**(`key`, `data`, `options?`): `Promise`\<`void`\> + +파일 업로드 + +#### Parameters + +##### key + +`string` + +파일 식별자 + +##### data + +파일 데이터 (Buffer 또는 Readable 스트림) + +`Readable` | `Buffer`\<`ArrayBufferLike`\> + +##### options? + +[`PutOptions`](/api/storage-core/src/type-aliases/putoptions/) + +업로드 옵션 + +#### Returns + +`Promise`\<`void`\> + +#### Overrides + +[`BaseStorageProvider`](/api/storage-core/src/classes/basestorageprovider/).[`put`](/api/storage-core/src/classes/basestorageprovider/#put) diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryRetryableUpstreamProblem.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryRetryableUpstreamProblem.md new file mode 100644 index 000000000..b001a6a4e --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryRetryableUpstreamProblem.md @@ -0,0 +1,294 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudinaryRetryableUpstreamProblem" +--- + +RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다. + +## Extends + +- [`Problem`](/api/problems-core/src/classes/problem/) + +## Constructors + +### Constructor + +> **new CloudinaryRetryableUpstreamProblem**(`context`): `CloudinaryRetryableUpstreamProblem` + +#### Parameters + +##### context + +[`CloudinaryStorageErrorContext`](/api/storage-cloudinary/src/type-aliases/cloudinarystorageerrorcontext/) + +#### Returns + +`CloudinaryRetryableUpstreamProblem` + +#### Overrides + +`Problem.constructor` + +## Properties + +### category + +> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) + +--- + +### cause? + +> `readonly` `optional` **cause**: `Error` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause) + +--- + +### code + +> `readonly` **code**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) + +--- + +### detail? + +> `readonly` `optional` **detail**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail) + +--- + +### extensions? + +> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions) + +--- + +### instance? + +> `readonly` `optional` **instance**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance) + +--- + +### message + +> **message**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message) + +--- + +### name + +> **name**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name) + +--- + +### stack? + +> `optional` **stack**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack) + +--- + +### type + +> `readonly` **type**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type) + +--- + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit) + +## Accessors + +### status + +#### Get Signature + +> **get** **status**(): `number` + +##### Returns + +`number` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status) + +--- + +### title + +#### Get Signature + +> **get** **title**(): `string` + +##### Returns + +`string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title) + +## Methods + +### toJSON() + +> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Returns + +[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson) + +--- + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace) + +--- + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace) diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryTerminalUpstreamProblem.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryTerminalUpstreamProblem.md new file mode 100644 index 000000000..9ba093ef7 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryTerminalUpstreamProblem.md @@ -0,0 +1,294 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudinaryTerminalUpstreamProblem" +--- + +RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다. + +## Extends + +- [`Problem`](/api/problems-core/src/classes/problem/) + +## Constructors + +### Constructor + +> **new CloudinaryTerminalUpstreamProblem**(`context`): `CloudinaryTerminalUpstreamProblem` + +#### Parameters + +##### context + +[`CloudinaryStorageErrorContext`](/api/storage-cloudinary/src/type-aliases/cloudinarystorageerrorcontext/) + +#### Returns + +`CloudinaryTerminalUpstreamProblem` + +#### Overrides + +`Problem.constructor` + +## Properties + +### category + +> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) + +--- + +### cause? + +> `readonly` `optional` **cause**: `Error` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause) + +--- + +### code + +> `readonly` **code**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) + +--- + +### detail? + +> `readonly` `optional` **detail**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail) + +--- + +### extensions? + +> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions) + +--- + +### instance? + +> `readonly` `optional` **instance**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance) + +--- + +### message + +> **message**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message) + +--- + +### name + +> **name**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name) + +--- + +### stack? + +> `optional` **stack**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack) + +--- + +### type + +> `readonly` **type**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type) + +--- + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit) + +## Accessors + +### status + +#### Get Signature + +> **get** **status**(): `number` + +##### Returns + +`number` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status) + +--- + +### title + +#### Get Signature + +> **get** **title**(): `string` + +##### Returns + +`string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title) + +## Methods + +### toJSON() + +> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Returns + +[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson) + +--- + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace) + +--- + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace) diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryValidationProblem.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryValidationProblem.md new file mode 100644 index 000000000..0d2d14cf6 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/classes/CloudinaryValidationProblem.md @@ -0,0 +1,298 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudinaryValidationProblem" +--- + +RFC 7807 Problem Details를 표현하는 기본 추상 에러 클래스입니다. + +## Extends + +- [`Problem`](/api/problems-core/src/classes/problem/) + +## Constructors + +### Constructor + +> **new CloudinaryValidationProblem**(`context`, `detail?`): `CloudinaryValidationProblem` + +#### Parameters + +##### context + +[`CloudinaryStorageErrorContext`](/api/storage-cloudinary/src/type-aliases/cloudinarystorageerrorcontext/) + +##### detail? + +`string` = `"Cloudinary storage request validation failed"` + +#### Returns + +`CloudinaryValidationProblem` + +#### Overrides + +`Problem.constructor` + +## Properties + +### category + +> `readonly` **category**: [`ProblemCategory`](/api/problems-core/src/enumerations/problemcategory/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`category`](/api/problems-core/src/classes/problem/#category) + +--- + +### cause? + +> `readonly` `optional` **cause**: `Error` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`cause`](/api/problems-core/src/classes/problem/#cause) + +--- + +### code + +> `readonly` **code**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`code`](/api/problems-core/src/classes/problem/#code) + +--- + +### detail? + +> `readonly` `optional` **detail**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`detail`](/api/problems-core/src/classes/problem/#detail) + +--- + +### extensions? + +> `readonly` `optional` **extensions**: [`ProblemExtensions`](/api/problems-core/src/type-aliases/problemextensions/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`extensions`](/api/problems-core/src/classes/problem/#extensions) + +--- + +### instance? + +> `readonly` `optional` **instance**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`instance`](/api/problems-core/src/classes/problem/#instance) + +--- + +### message + +> **message**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`message`](/api/problems-core/src/classes/problem/#message) + +--- + +### name + +> **name**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`name`](/api/problems-core/src/classes/problem/#name) + +--- + +### stack? + +> `optional` **stack**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stack`](/api/problems-core/src/classes/problem/#stack) + +--- + +### type + +> `readonly` **type**: `string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`type`](/api/problems-core/src/classes/problem/#type) + +--- + +### stackTraceLimit + +> `static` **stackTraceLimit**: `number` + +The `Error.stackTraceLimit` property specifies the number of stack frames +collected by a stack trace (whether generated by `new Error().stack` or +`Error.captureStackTrace(obj)`). + +The default value is `10` but may be set to any valid JavaScript number. Changes +will affect any stack trace captured _after_ the value has been changed. + +If set to a non-number value, or set to a negative number, stack traces will +not capture any frames. + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`stackTraceLimit`](/api/problems-core/src/classes/problem/#stacktracelimit) + +## Accessors + +### status + +#### Get Signature + +> **get** **status**(): `number` + +##### Returns + +`number` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`status`](/api/problems-core/src/classes/problem/#status) + +--- + +### title + +#### Get Signature + +> **get** **title**(): `string` + +##### Returns + +`string` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`title`](/api/problems-core/src/classes/problem/#title) + +## Methods + +### toJSON() + +> **toJSON**(): [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Returns + +[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/) + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`toJSON`](/api/problems-core/src/classes/problem/#tojson) + +--- + +### captureStackTrace() + +> `static` **captureStackTrace**(`targetObject`, `constructorOpt?`): `void` + +Creates a `.stack` property on `targetObject`, which when accessed returns +a string representing the location in the code at which +`Error.captureStackTrace()` was called. + +```js +const myObject = {}; +Error.captureStackTrace(myObject); +myObject.stack; // Similar to `new Error().stack` +``` + +The first line of the trace will be prefixed with +`${myObject.name}: ${myObject.message}`. + +The optional `constructorOpt` argument accepts a function. If given, all frames +above `constructorOpt`, including `constructorOpt`, will be omitted from the +generated stack trace. + +The `constructorOpt` argument is useful for hiding implementation +details of error generation from the user. For instance: + +```js +function a() { + b(); +} + +function b() { + c(); +} + +function c() { + // Create an error without stack trace to avoid calculating the stack trace twice. + const { stackTraceLimit } = Error; + Error.stackTraceLimit = 0; + const error = new Error(); + Error.stackTraceLimit = stackTraceLimit; + + // Capture the stack trace above function b + Error.captureStackTrace(error, b); // Neither function c, nor b is included in the stack trace + throw error; +} + +a(); +``` + +#### Parameters + +##### targetObject + +`object` + +##### constructorOpt? + +`Function` + +#### Returns + +`void` + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`captureStackTrace`](/api/problems-core/src/classes/problem/#capturestacktrace) + +--- + +### prepareStackTrace() + +> `static` **prepareStackTrace**(`err`, `stackTraces`): `any` + +#### Parameters + +##### err + +`Error` + +##### stackTraces + +`CallSite`[] + +#### Returns + +`any` + +#### See + +https://v8.dev/docs/stack-trace-api#customizing-stack-traces + +#### Inherited from + +[`Problem`](/api/problems-core/src/classes/problem/).[`prepareStackTrace`](/api/problems-core/src/classes/problem/#preparestacktrace) diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/getCloudinaryErrorMessage.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/getCloudinaryErrorMessage.md new file mode 100644 index 000000000..7c3502bf6 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/getCloudinaryErrorMessage.md @@ -0,0 +1,22 @@ +--- +editUrl: false +next: false +prev: false +title: "getCloudinaryErrorMessage" +--- + +> **getCloudinaryErrorMessage**(`error`, `fallback`): `string` + +## Parameters + +### error + +`unknown` + +### fallback + +`string` + +## Returns + +`string` diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/isRetryableCloudinaryStorageError.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/isRetryableCloudinaryStorageError.md new file mode 100644 index 000000000..f93d934d1 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/isRetryableCloudinaryStorageError.md @@ -0,0 +1,22 @@ +--- +editUrl: false +next: false +prev: false +title: "isRetryableCloudinaryStorageError" +--- + +> **isRetryableCloudinaryStorageError**(`error`, `knownContext?`): `boolean` + +## Parameters + +### error + +`unknown` + +### knownContext? + +[`CloudinaryStorageErrorContext`](/api/storage-cloudinary/src/type-aliases/cloudinarystorageerrorcontext/) + +## Returns + +`boolean` diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/normalizeCloudinaryStorageError.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/normalizeCloudinaryStorageError.md new file mode 100644 index 000000000..127a7cd29 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/normalizeCloudinaryStorageError.md @@ -0,0 +1,36 @@ +--- +editUrl: false +next: false +prev: false +title: "normalizeCloudinaryStorageError" +--- + +> **normalizeCloudinaryStorageError**(`error`, `options`): [`Problem`](/api/problems-core/src/classes/problem/) + +## Parameters + +### error + +`unknown` + +### options + +#### key? + +`string` + +#### operation + +`string` + +#### status? + +`number` + +#### upstreamCode? + +`string` + +## Returns + +[`Problem`](/api/problems-core/src/classes/problem/) diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/validateCloudinaryConfig.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/validateCloudinaryConfig.md new file mode 100644 index 000000000..3d63edf25 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/functions/validateCloudinaryConfig.md @@ -0,0 +1,18 @@ +--- +editUrl: false +next: false +prev: false +title: "validateCloudinaryConfig" +--- + +> **validateCloudinaryConfig**(`config`): [`CloudinaryConfig`](/api/storage-cloudinary/src/type-aliases/cloudinaryconfig/) + +## Parameters + +### config + +`Partial`\<[`CloudinaryConfig`](/api/storage-cloudinary/src/type-aliases/cloudinaryconfig/)\> + +## Returns + +[`CloudinaryConfig`](/api/storage-cloudinary/src/type-aliases/cloudinaryconfig/) diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryConfig.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryConfig.md new file mode 100644 index 000000000..dc2dccde0 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryConfig.md @@ -0,0 +1,56 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudinaryConfig" +--- + +> **CloudinaryConfig** = `object` + +Cloudinary 제공자 설정입니다. + +## Properties + +### apiKey + +> **apiKey**: `string` + +Cloudinary API Key + +--- + +### apiSecret + +> **apiSecret**: `string` + +Cloudinary API Secret + +--- + +### cloudName + +> **cloudName**: `string` + +Cloudinary 클라우드 이름 + +--- + +### secure? + +> `optional` **secure**: `boolean` + +HTTPS 사용 여부 (기본값: true) + +--- + +### ttl? + +> `optional` **ttl**: `number` + +Upload Intent TTL (초 단위, 기본값: 3600) + +--- + +### uploadBaseUrl? + +> `optional` **uploadBaseUrl**: `string` diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryConfigKey.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryConfigKey.md new file mode 100644 index 000000000..be0f31249 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryConfigKey.md @@ -0,0 +1,10 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudinaryConfigKey" +--- + +> **CloudinaryConfigKey** = `"apiKey"` \| `"apiSecret"` \| `"cloudName"` + +Cloudinary 제공자 구성과 확장 옵션에 필요한 공개 타입들입니다. diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryDiagnosticsOptions.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryDiagnosticsOptions.md new file mode 100644 index 000000000..21d113122 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryDiagnosticsOptions.md @@ -0,0 +1,26 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudinaryDiagnosticsOptions" +--- + +> **CloudinaryDiagnosticsOptions** = `object` + +Cloudinary 제공자 구성과 확장 옵션에 필요한 공개 타입들입니다. + +## Properties + +### readinessCheck()? + +> `readonly` `optional` **readinessCheck**: (`context`) => `Promise`\<[`CloudinaryReadinessCheckResult`](/api/storage-cloudinary/src/type-aliases/cloudinaryreadinesscheckresult/) \| `void`\> + +#### Parameters + +##### context + +[`CloudinaryReadinessCheckContext`](/api/storage-cloudinary/src/type-aliases/cloudinaryreadinesscheckcontext/) + +#### Returns + +`Promise`\<[`CloudinaryReadinessCheckResult`](/api/storage-cloudinary/src/type-aliases/cloudinaryreadinesscheckresult/) \| `void`\> diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryReadinessCheckContext.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryReadinessCheckContext.md new file mode 100644 index 000000000..22a9559a1 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryReadinessCheckContext.md @@ -0,0 +1,22 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudinaryReadinessCheckContext" +--- + +> **CloudinaryReadinessCheckContext** = `object` + +Cloudinary 제공자 구성과 확장 옵션에 필요한 공개 타입들입니다. + +## Properties + +### config + +> `readonly` **config**: [`CloudinaryConfig`](/api/storage-cloudinary/src/type-aliases/cloudinaryconfig/) + +--- + +### signal? + +> `readonly` `optional` **signal**: `AbortSignal` diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryReadinessCheckResult.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryReadinessCheckResult.md new file mode 100644 index 000000000..084739845 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryReadinessCheckResult.md @@ -0,0 +1,22 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudinaryReadinessCheckResult" +--- + +> **CloudinaryReadinessCheckResult** = `object` + +Cloudinary 제공자 구성과 확장 옵션에 필요한 공개 타입들입니다. + +## Properties + +### details? + +> `readonly` `optional` **details**: `Record`\<`string`, `unknown`\> + +--- + +### message? + +> `readonly` `optional` **message**: `string` diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryStorageErrorContext.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryStorageErrorContext.md new file mode 100644 index 000000000..1a33bf628 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryStorageErrorContext.md @@ -0,0 +1,46 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudinaryStorageErrorContext" +--- + +> **CloudinaryStorageErrorContext** = `object` + +Cloudinary 제공자 구성과 확장 옵션에 필요한 공개 타입들입니다. + +## Properties + +### key? + +> `readonly` `optional` **key**: `string` + +--- + +### operation + +> `readonly` **operation**: `string` + +--- + +### provider + +> `readonly` **provider**: `"cloudinary"` + +--- + +### retryable? + +> `readonly` `optional` **retryable**: `boolean` + +--- + +### status? + +> `readonly` `optional` **status**: `number` + +--- + +### upstreamCode? + +> `readonly` `optional` **upstreamCode**: `string` diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryTransformOptions.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryTransformOptions.md new file mode 100644 index 000000000..dfbbfbdf8 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryTransformOptions.md @@ -0,0 +1,46 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudinaryTransformOptions" +--- + +> **CloudinaryTransformOptions** = `object` + +Cloudinary 변환 파라미터 타입입니다. + +## Properties + +### crop? + +> `optional` **crop**: `"scale"` \| `"fit"` \| `"fill"` \| `"limit"` \| `"pad"` \| `"crop"` \| `"thumb"` + +--- + +### dpr? + +> `optional` **dpr**: `number` + +--- + +### format? + +> `optional` **format**: `string` + +--- + +### height? + +> `optional` **height**: `number` + +--- + +### quality? + +> `optional` **quality**: `number` + +--- + +### width? + +> `optional` **width**: `number` diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryUploadOptions.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryUploadOptions.md new file mode 100644 index 000000000..cca7e5544 --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/type-aliases/CloudinaryUploadOptions.md @@ -0,0 +1,58 @@ +--- +editUrl: false +next: false +prev: false +title: "CloudinaryUploadOptions" +--- + +> **CloudinaryUploadOptions** = `object` + +Cloudinary 업로드에 사용할 확장 옵션입니다. + +## Properties + +### context? + +> `optional` **context**: `Record`\<`string`, `string`\> + +컨텍스트 메타데이터 (key-value 쌍) + +--- + +### eager? + +> `optional` **eager**: `unknown`[] + +업로드 시 적용할 변환 (eager transformations) + +--- + +### folder? + +> `optional` **folder**: `string` + +업로드할 폴더 경로 + +--- + +### publicId? + +> `optional` **publicId**: `string` + +사용자 정의 public ID (key) + +--- + +### resourceType? + +> `optional` **resourceType**: `"image"` \| `"video"` \| `"raw"` + +리소스 타입 + +--- + +### tags? + +> `optional` **tags**: `string`[] + +태그 목록 diff --git a/packages/docs/src/content/docs/api/storage-cloudinary/src/variables/CLOUDINARY_CONFIG.md b/packages/docs/src/content/docs/api/storage-cloudinary/src/variables/CLOUDINARY_CONFIG.md new file mode 100644 index 000000000..52fcfa1ff --- /dev/null +++ b/packages/docs/src/content/docs/api/storage-cloudinary/src/variables/CLOUDINARY_CONFIG.md @@ -0,0 +1,10 @@ +--- +editUrl: false +next: false +prev: false +title: "CLOUDINARY_CONFIG" +--- + +> `const` **CLOUDINARY_CONFIG**: _typeof_ `CLOUDINARY_CONFIG` + +Cloudinary 제공자 설정 DI 토큰 diff --git a/packages/docs/src/content/docs/api/storage-core/src/classes/BaseStorageProvider.md b/packages/docs/src/content/docs/api/storage-core/src/classes/BaseStorageProvider.md index 538de3da9..e7d6f3d3c 100644 --- a/packages/docs/src/content/docs/api/storage-core/src/classes/BaseStorageProvider.md +++ b/packages/docs/src/content/docs/api/storage-core/src/classes/BaseStorageProvider.md @@ -7,6 +7,11 @@ title: "BaseStorageProvider" 스토리지 제공자 구현을 위한 기본 추상 클래스입니다. +## Extended by + +- [`CloudflareImagesProvider`](/api/storage-cloudflare/src/classes/cloudflareimagesprovider/) +- [`CloudinaryProvider`](/api/storage-cloudinary/src/classes/cloudinaryprovider/) + ## Implements - [`StorageProvider`](/api/storage-core/src/type-aliases/storageprovider/) @@ -45,7 +50,7 @@ title: "BaseStorageProvider" `StorageProvider.delete` -*** +--- ### exists() @@ -69,7 +74,7 @@ title: "BaseStorageProvider" `StorageProvider.exists` -*** +--- ### get() @@ -99,7 +104,7 @@ FileNotFoundProblem - 파일이 존재하지 않을 때 `StorageProvider.get` -*** +--- ### getMetadata() @@ -129,7 +134,7 @@ FileNotFoundProblem - 파일이 존재하지 않을 때 `StorageProvider.getMetadata` -*** +--- ### getPublicUrl() @@ -155,7 +160,7 @@ FileNotFoundProblem - 파일이 존재하지 않을 때 `StorageProvider.getPublicUrl` -*** +--- ### getSignedUrl() @@ -187,7 +192,7 @@ FileNotFoundProblem - 파일이 존재하지 않을 때 `StorageProvider.getSignedUrl` -*** +--- ### getStream() @@ -217,7 +222,7 @@ FileNotFoundProblem - 파일이 존재하지 않을 때 `StorageProvider.getStream` -*** +--- ### put() diff --git a/packages/docs/src/content/docs/en/reference/extension-matrix.md b/packages/docs/src/content/docs/en/reference/extension-matrix.md index 0d6f32287..62acb0397 100644 --- a/packages/docs/src/content/docs/en/reference/extension-matrix.md +++ b/packages/docs/src/content/docs/en/reference/extension-matrix.md @@ -38,8 +38,8 @@ Runtime columns: Node covers long-running server and CLI use, Lambda covers serv | `@croco/ratelimit-upstash` | Rate limiting | Upstash Redis rate-limit store | yes | yes | - | - | UPSTASH_REDIS_REST_URL
UPSTASH_REDIS_REST_TOKEN | @upstash/redis | sliding window
token bucket
fixed window
Lua atomicity
shared conformance
redacted upstream Problems | 🔴 alpha/WIP | has package tests | | `@croco/search-drizzle` | Search | Drizzle search index | yes | yes | - | - | database connection supplied by app | drizzle-orm | search document persistence
tenant-aware lookup | 🔴 alpha/WIP | has package tests | | `@croco/search-meilisearch` | Search | Meilisearch engine | yes | yes | - | - | MEILISEARCH_HOST
MEILISEARCH_API_KEY | - | indexing
search
tenant tokens | 🔴 alpha/WIP | has package tests | -| `@croco/storage-cloudflare` | Storage | Cloudflare Images provider | yes | yes | - | - | CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_API_TOKEN
CLOUDFLARE_ACCOUNT_HASH | - | image upload
transform URLs
upload intents
signed URLs | 🔴 alpha/WIP | has package tests | -| `@croco/storage-cloudinary` | Storage | Cloudinary provider | yes | yes | - | - | CLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY
CLOUDINARY_API_SECRET | - | file upload
transform URLs
upload intents
retry | 🔴 alpha/WIP | has package tests | +| `@croco/storage-cloudflare` | Storage | Cloudflare Images provider | yes | yes | - | - | CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_API_TOKEN
CLOUDFLARE_ACCOUNT_HASH | - | image upload
transform URLs
upload intents
signed URLs
storage conformance
diagnostics
optional live smoke | 🔴 alpha/WIP | has package tests | +| `@croco/storage-cloudinary` | Storage | Cloudinary provider | yes | yes | - | - | CLOUDINARY_CLOUD_NAME
CLOUDINARY_API_KEY
CLOUDINARY_API_SECRET | - | file upload
transform URLs
upload intents
retry
storage conformance
diagnostics
optional live smoke | 🟡 beta | has package tests | | `@croco/storage-r2` | Storage | Cloudflare R2 S3-compatible provider | yes | yes | - | - | R2_ACCOUNT_ID
R2_ACCESS_KEY_ID
R2_SECRET_ACCESS_KEY
R2_BUCKET | - | put/get/delete
signed URLs
stream reads
retry
safe diagnostics
env-gated live smoke | 🟡 beta | has package tests | | `@croco/tasks-qstash` | Tasks | QStash task runner | yes | yes | - | - | UPSTASH_QSTASH_TOKEN
UPSTASH_QSTASH_DESTINATION_URL | - | task publish
delay override
custom headers
deduplication id
shared conformance
redacted upstream Problems | 🔴 alpha/WIP | has package tests | | `@croco/triggers-qstash` | Triggers | QStash scheduler and webhook handler | yes | yes | - | - | QSTASH_TOKEN
public webhook URL | - | schedule publish
webhook verification
trigger dispatch
shared conformance
redacted schedule diagnostics
diagnostic-coded webhook failures | 🔴 alpha/WIP | has package tests | diff --git a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md index 4fcd52172..1c2778680 100644 --- a/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md +++ b/packages/docs/src/content/docs/en/reference/problem-recovery-cookbook.md @@ -7,7 +7,7 @@ description: Generated Croco Problem code registry with recovery and telemetry m > Generated by `pnpm problem-registry:write`. Do not edit this file by hand. -This cookbook documents 367 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`. +This cookbook documents 375 public Croco Problem codes. The deterministic JSON registry is generated at `docs/problem-code-registry.json`. ## Index @@ -305,7 +305,15 @@ This cookbook documents 367 public Croco Problem codes. The deterministic JSON r | [`STORAGE_R2_OBJECT_TOO_LARGE`](#storage-r2-object-too-large) | InternalServerError | 500 | conditional | operator-only | 1 | | [`STORAGE_R2_READINESS_FAILED`](#storage-r2-readiness-failed) | InternalServerError | 500 | conditional | operator-only | 1 | | [`STORAGE_UPLOAD_FAILED`](#storage-upload-failed) | InternalServerError | 500 | conditional | operator-only | 1 | +| [`storage-cloudflare/missing-config`](#storage-cloudflare-missing-config) | InternalServerError | 500 | conditional | operator-only | 1 | +| [`storage-cloudflare/retryable-upstream`](#storage-cloudflare-retryable-upstream) | InternalServerError | 500 | conditional | operator-only | 1 | +| [`storage-cloudflare/terminal-upstream`](#storage-cloudflare-terminal-upstream) | InternalServerError | 500 | conditional | operator-only | 1 | +| [`storage-cloudflare/validation-failed`](#storage-cloudflare-validation-failed) | ValidationError | 422 | not-retryable | public | 1 | | [`storage-cloudinary/invalid-upload-intent-ttl`](#storage-cloudinary-invalid-upload-intent-ttl) | BadRequest | 400 | not-retryable | public | 1 | +| [`storage-cloudinary/missing-config`](#storage-cloudinary-missing-config) | InternalServerError | 500 | conditional | operator-only | 1 | +| [`storage-cloudinary/retryable-upstream`](#storage-cloudinary-retryable-upstream) | InternalServerError | 500 | conditional | operator-only | 1 | +| [`storage-cloudinary/terminal-upstream`](#storage-cloudinary-terminal-upstream) | InternalServerError | 500 | conditional | operator-only | 1 | +| [`storage-cloudinary/validation-failed`](#storage-cloudinary-validation-failed) | ValidationError | 422 | not-retryable | public | 1 | | [`storage/invalid-upload-intent-ttl`](#storage-invalid-upload-intent-ttl) | BadRequest | 400 | not-retryable | public | 1 | | [`STRATEGY_UNAVAILABLE`](#strategy-unavailable) | InternalServerError | 500 | conditional | operator-only | 1 | | [`STRUCTURED_OUTPUT_ERROR`](#structured-output-error) | InternalServerError | 500 | conditional | operator-only | 1 | @@ -1552,7 +1560,7 @@ Sources: Sources: -- `packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts:44:13` (problem-factory) +- `packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts:48:13` (problem-factory) @@ -1569,7 +1577,7 @@ Sources: Sources: -- `packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts:206:13` (problem-factory) +- `packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts:256:13` (problem-factory) @@ -1586,7 +1594,7 @@ Sources: Sources: -- `packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts:267:13` (problem-factory) +- `packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts:331:13` (problem-factory) @@ -5345,6 +5353,74 @@ Sources: - `packages/storage-core/src/libs/problems/UploadFailedProblem.ts:11:5` (problem-constructor) + + +## `storage-cloudflare/missing-config` + +- Category: `InternalServerError` +- HTTP status: `500` Internal Server Error +- Retryability: `conditional` +- Redaction policy: `operator-only` +- Cause: Croco or an upstream dependency failed after accepting the request. +- User action: Retry later only when the operation is idempotent or the caller owns retry safety. +- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary. +- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts:37:5` (problem-constructor) + + + +## `storage-cloudflare/retryable-upstream` + +- Category: `InternalServerError` +- HTTP status: `500` Internal Server Error +- Retryability: `conditional` +- Redaction policy: `operator-only` +- Cause: Croco or an upstream dependency failed after accepting the request. +- User action: Retry later only when the operation is idempotent or the caller owns retry safety. +- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary. +- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts:70:5` (problem-constructor) + + + +## `storage-cloudflare/terminal-upstream` + +- Category: `InternalServerError` +- HTTP status: `500` Internal Server Error +- Retryability: `conditional` +- Redaction policy: `operator-only` +- Cause: Croco or an upstream dependency failed after accepting the request. +- User action: Retry later only when the operation is idempotent or the caller owns retry safety. +- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary. +- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts:86:5` (problem-constructor) + + + +## `storage-cloudflare/validation-failed` + +- Category: `ValidationError` +- HTTP status: `422` Validation Error +- Retryability: `not-retryable` +- Redaction policy: `public` +- Cause: The request or generated contract failed schema or semantic validation. +- User action: Fix the invalid fields and retry with schema-conformant input. +- Operator action: Inspect schema diagnostics, generated contracts, and validation metadata. +- Telemetry: `croco.problem.info` (info) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts:57:5` (problem-constructor) + ## `storage-cloudinary/invalid-upload-intent-ttl` @@ -5360,7 +5436,75 @@ Sources: Sources: -- `packages/storage-cloudinary/src/libs/CloudinaryProvider.ts:396:13` (problem-factory) +- `packages/storage-cloudinary/src/libs/CloudinaryProvider.ts:331:13` (problem-factory) + + + +## `storage-cloudinary/missing-config` + +- Category: `InternalServerError` +- HTTP status: `500` Internal Server Error +- Retryability: `conditional` +- Redaction policy: `operator-only` +- Cause: Croco or an upstream dependency failed after accepting the request. +- User action: Retry later only when the operation is idempotent or the caller owns retry safety. +- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary. +- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts:51:5` (problem-constructor) + + + +## `storage-cloudinary/retryable-upstream` + +- Category: `InternalServerError` +- HTTP status: `500` Internal Server Error +- Retryability: `conditional` +- Redaction policy: `operator-only` +- Cause: Croco or an upstream dependency failed after accepting the request. +- User action: Retry later only when the operation is idempotent or the caller owns retry safety. +- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary. +- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts:84:5` (problem-constructor) + + + +## `storage-cloudinary/terminal-upstream` + +- Category: `InternalServerError` +- HTTP status: `500` Internal Server Error +- Retryability: `conditional` +- Redaction policy: `operator-only` +- Cause: Croco or an upstream dependency failed after accepting the request. +- User action: Retry later only when the operation is idempotent or the caller owns retry safety. +- Operator action: Use traces, logs, and upstream diagnostics to isolate the failing boundary. +- Telemetry: `croco.problem.error` (error) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts:100:5` (problem-constructor) + + + +## `storage-cloudinary/validation-failed` + +- Category: `ValidationError` +- HTTP status: `422` Validation Error +- Retryability: `not-retryable` +- Redaction policy: `public` +- Cause: The request or generated contract failed schema or semantic validation. +- User action: Fix the invalid fields and retry with schema-conformant input. +- Operator action: Inspect schema diagnostics, generated contracts, and validation metadata. +- Telemetry: `croco.problem.info` (info) with `problem.code`, `problem.category`, `problem.status` + +Sources: + +- `packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts:71:5` (problem-constructor) @@ -5377,7 +5521,7 @@ Sources: Sources: -- `packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts:233:13` (problem-factory) +- `packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts:283:13` (problem-factory) diff --git a/packages/docs/src/content/docs/en/reference/provider-maturity.md b/packages/docs/src/content/docs/en/reference/provider-maturity.md index 465d54941..753decb0b 100644 --- a/packages/docs/src/content/docs/en/reference/provider-maturity.md +++ b/packages/docs/src/content/docs/en/reference/provider-maturity.md @@ -61,10 +61,11 @@ The current `@croco/storage-core` `StorageProvider` contract does not expose `li The first consumers are: -| Package | Harness evidence | Promotion result | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `@croco/storage-r2` | Uses the storage conformance suite with a stateful mocked S3/R2 backend, requires content type plus custom metadata preservation, exposes safe diagnostics/readiness, and documents an env-gated live R2 smoke. | Remains beta. It passes default conformance and diagnostics locally, but production-ready still requires recorded optional live R2 smoke evidence with real credentials. | -| `@croco/storage-cloudflare` | Uses the storage conformance suite with an in-memory Cloudflare Images fetch backend. Metadata preservation is marked unsupported because the current provider metadata contract returns size and upload time only. | Remains alpha. It has shared contract coverage, but metadata limits, diagnostics, and live smoke documentation still block beta/production promotion. | +| Package | Harness evidence | Promotion result | +| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `@croco/storage-r2` | Uses the storage conformance suite with a stateful mocked S3/R2 backend, requires content type plus custom metadata preservation, exposes safe diagnostics/readiness, and documents an env-gated live R2 smoke. | Remains beta. It passes default conformance and diagnostics locally, but production-ready still requires recorded optional live R2 smoke evidence with real credentials. | +| `@croco/storage-cloudflare` | Uses the storage conformance suite with an in-memory Cloudflare Images fetch backend, exposes safe diagnostics/readiness, and includes an opt-in live-smoke gate. Metadata preservation is marked unsupported because the current provider metadata contract returns size and upload time only. | Remains alpha. It has shared contract coverage and readiness diagnostics, but Cloudflare Images metadata limits still block beta/production promotion. | +| `@croco/storage-cloudinary` | Uses the storage conformance suite with a mocked Cloudinary SDK/fetch backend, preserves custom metadata, reports content type as Cloudinary format metadata, exposes safe diagnostics/readiness, and includes an opt-in live-smoke gate. | Promoted to beta. It has default conformance and diagnostics evidence, but production-ready still requires recorded real-backend live Cloudinary smoke evidence. | ### Billing provider conformance diff --git a/packages/docs/tsconfig.typedoc.json b/packages/docs/tsconfig.typedoc.json index 9da701c7f..0cdbcacac 100644 --- a/packages/docs/tsconfig.typedoc.json +++ b/packages/docs/tsconfig.typedoc.json @@ -59,6 +59,8 @@ "../retry-core/src/index.ts", "../rpc-codegen/src/index.ts", "../search-core/src/index.ts", + "../storage-cloudflare/src/index.ts", + "../storage-cloudinary/src/index.ts", "../storage-core/src/index.ts", "../telemetry-api/src/index.ts", "../telemetry-sdk-node/src/index.ts", diff --git a/packages/storage-cloudflare/README.md b/packages/storage-cloudflare/README.md index 2c648cd65..fcaa0fef8 100644 --- a/packages/storage-cloudflare/README.md +++ b/packages/storage-cloudflare/README.md @@ -47,18 +47,63 @@ const transformUrl = provider.getTransformUrl("avatars/user-1.jpg", { ## API 레퍼런스 -| API | 설명 | -| ---------------------------------------------------- | ----------------------------------------------------------------------- | -| `CloudflareImagesProvider` | 이미지 업로드, 다운로드, 삭제, 메타데이터 조회를 처리합니다. | -| `getTransformUrl()` | width, height, fit, format, quality를 Cloudflare 파라미터로 변환합니다. | -| `getUploadIntent()` | 클라이언트 직접 업로드용 URL과 만료 시간을 돌려줍니다. | -| `CLOUDFLARE_IMAGES_OPTIONS` | DI 등록용 토큰입니다. | -| `CloudflareImagesOptions` | 제공자 설정 타입입니다. | -| `CloudflareUploadResponse`, `CloudflareImageDetails` | Cloudflare 응답 구조 타입입니다. | -| `CloudflareTransformOptions` | Cloudflare 고유 변환 옵션 타입입니다. | +| API | 설명 | +| ---------------------------------------------------- | ----------------------------------------------------------------------------- | +| `CloudflareImagesProvider` | 이미지 업로드, 다운로드, 삭제, 메타데이터 조회를 처리합니다. | +| `CloudflareImagesDiagnosticsProvider` | 안전한 설정 상태와 선택적 readiness check 결과를 `HealthStatus`로 노출합니다. | +| `getTransformUrl()` | width, height, fit, format, quality를 Cloudflare 파라미터로 변환합니다. | +| `getUploadIntent()` | 클라이언트 직접 업로드용 URL과 만료 시간을 돌려줍니다. | +| `CLOUDFLARE_IMAGES_OPTIONS` | DI 등록용 토큰입니다. | +| `validateCloudflareImagesOptions()` | 필수 설정과 양수 정수 옵션을 검증합니다. | +| `normalizeCloudflareImagesError()` | Cloudflare/fetch 실패를 provider 전용 `Problem`으로 정규화합니다. | +| `CloudflareImagesOptions` | 제공자 설정 타입입니다. | +| `CloudflareUploadResponse`, `CloudflareImageDetails` | Cloudflare 응답 구조 타입입니다. | +| `CloudflareTransformOptions` | Cloudflare 고유 변환 옵션 타입입니다. | + +## 진단과 readiness + +```typescript +import { CloudflareImagesDiagnosticsProvider } from "@croco/storage-cloudflare"; + +const diagnostics = new CloudflareImagesDiagnosticsProvider({ + accountId: process.env.CLOUDFLARE_ACCOUNT_ID, + apiToken: process.env.CLOUDFLARE_API_TOKEN, + accountHash: process.env.CLOUDFLARE_ACCOUNT_HASH, +}); + +const health = await diagnostics.getHealth(); +``` + +- 필수 설정이 빠지면 `unhealthy` 상태와 `storage-cloudflare/missing-config` 코드가 반환됩니다. +- `readinessCheck`를 넘기지 않으면 외부 API를 호출하지 않고 설정 존재 여부만 `healthy`로 보고합니다. +- `readinessCheck`가 실패하면 `degraded` 상태와 정규화된 provider Problem 코드가 반환됩니다. +- 진단 detail은 토큰, secret, authorization 값을 redaction 처리합니다. + +## 실패 코드 + +| 코드 | 의미 | +| --------------------------------------- | ------------------------------------------------------------- | +| `storage-cloudflare/missing-config` | `accountId`, `apiToken`, `accountHash` 중 하나가 없음 | +| `storage-cloudflare/validation-failed` | 4xx 응답, 잘못된 TTL, 서명 키 누락 등 복구 불가능한 입력 실패 | +| `storage-cloudflare/retryable-upstream` | 408, 425, 429, 5xx 또는 네트워크 계열 재시도 가능 실패 | +| `storage-cloudflare/terminal-upstream` | 재시도 대상으로 분류되지 않는 Cloudflare/fetch 실패 | + +## 선택적 live smoke + +기본 테스트는 실제 Cloudflare 자격 증명을 요구하지 않습니다. 실제 backend readiness를 확인하려면 아래 환경 변수를 모두 설정한 뒤 live smoke를 opt-in 합니다. + +```bash +CROCO_LIVE_CLOUDFLARE_IMAGES=1 \ +CLOUDFLARE_ACCOUNT_ID=... \ +CLOUDFLARE_API_TOKEN=... \ +CLOUDFLARE_ACCOUNT_HASH=... \ +pnpm --filter @croco/storage-cloudflare test -- CloudflareImagesLiveSmoke +``` ## 동작 메모 - Cloudflare Images는 이미지 전용 서비스입니다. +- `StorageProvider`의 `list()` 계약은 아직 존재하지 않으므로 provider도 목록 조회를 제공하지 않습니다. +- `put()`의 `contentType`은 업로드 파일 MIME으로만 전달됩니다. `getMetadata()`는 현재 size와 upload time만 반환하며, content type과 custom metadata 보존은 지원하지 않습니다. - 서명 URL은 `signingKey`가 없으면 생성할 수 없습니다. - `inside`는 `scale-down`, `outside`는 `cover`로 매핑합니다. diff --git a/packages/storage-cloudflare/package.json b/packages/storage-cloudflare/package.json index f19220a5f..b9e0b75aa 100644 --- a/packages/storage-cloudflare/package.json +++ b/packages/storage-cloudflare/package.json @@ -32,6 +32,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@croco/diagnostics-core": "workspace:*", "@croco/framework-context": "workspace:*", "@croco/problems-core": "workspace:*", "@croco/storage-core": "workspace:*" diff --git a/packages/storage-cloudflare/src/index.ts b/packages/storage-cloudflare/src/index.ts index 3e6375ffd..9efeadd5a 100644 --- a/packages/storage-cloudflare/src/index.ts +++ b/packages/storage-cloudflare/src/index.ts @@ -2,6 +2,16 @@ * Cloudflare Images 기반 스토리지 및 이미지 변환 제공자 구현체입니다. */ export { CloudflareImagesProvider } from "./libs/CloudflareImagesProvider"; +export { + CloudflareImagesDiagnosticsProvider, + CloudflareImagesMissingConfigProblem, + CloudflareImagesRetryableUpstreamProblem, + CloudflareImagesTerminalUpstreamProblem, + CloudflareImagesValidationProblem, + createCloudflareImagesResponseProblem, + normalizeCloudflareImagesError, + validateCloudflareImagesOptions, +} from "./libs/CloudflareImagesDiagnosticsProvider"; /** * Cloudflare Images 옵션을 DI 컨테이너에 등록할 때 사용하는 토큰입니다. @@ -11,6 +21,13 @@ export { CLOUDFLARE_IMAGES_OPTIONS } from "./libs/tokens"; /** * Cloudflare Images 제공자 구성과 API 응답에 필요한 공개 타입들입니다. */ +export type { + CloudflareImagesConfigKey, + CloudflareImagesDiagnosticsOptions, + CloudflareImagesErrorContext, + CloudflareImagesReadinessCheckContext, + CloudflareImagesReadinessCheckResult, +} from "./libs/CloudflareImagesDiagnosticsProvider"; export type { CloudflareImageDetails, CloudflareImagesOptions, diff --git a/packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts b/packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts new file mode 100644 index 000000000..0fcd9c9ed --- /dev/null +++ b/packages/storage-cloudflare/src/libs/CloudflareImagesDiagnosticsProvider.ts @@ -0,0 +1,401 @@ +import type { DiagnosticsProvider, HealthStatus } from "@croco/diagnostics-core"; +import { Problem, ProblemCategory } from "@croco/problems-core"; +import type { CloudflareImagesOptions } from "./types"; + +export type CloudflareImagesErrorContext = { + readonly provider: "cloudflare-images"; + readonly operation: string; + readonly key?: string; + readonly status?: number; + readonly upstreamCode?: string; + readonly retryable?: boolean; +}; + +export type CloudflareImagesReadinessCheckContext = { + readonly config: CloudflareImagesOptions; + readonly signal?: AbortSignal; +}; + +export type CloudflareImagesReadinessCheckResult = { + readonly message?: string; + readonly details?: Record; +}; + +export type CloudflareImagesDiagnosticsOptions = { + readonly readinessCheck?: ( + context: CloudflareImagesReadinessCheckContext, + ) => Promise; +}; + +export type CloudflareImagesConfigKey = "accountHash" | "accountId" | "apiToken"; + +/** + * Problem raised when Cloudflare Images storage is used without a required configuration value. + */ +export class CloudflareImagesMissingConfigProblem extends Problem { + constructor(configKey: CloudflareImagesConfigKey, operation = "configuration") { + super( + "storage-cloudflare/missing-config", + ProblemCategory.InternalServerError, + `Cloudflare Images configuration is missing required value '${configKey}'`, + { + extensions: { + provider: "cloudflare-images", + operation, + configKey, + }, + }, + ); + } +} + +export class CloudflareImagesValidationProblem extends Problem { + constructor( + context: CloudflareImagesErrorContext, + detail = "Cloudflare Images request validation failed", + ) { + super( + "storage-cloudflare/validation-failed", + ProblemCategory.ValidationError, + `${detail} during ${context.operation}`, + { + extensions: context, + }, + ); + } +} + +export class CloudflareImagesRetryableUpstreamProblem extends Problem { + constructor(context: CloudflareImagesErrorContext) { + super( + "storage-cloudflare/retryable-upstream", + ProblemCategory.InternalServerError, + `Cloudflare Images upstream request failed retryably during ${context.operation}`, + { + extensions: { + ...context, + retryable: true, + }, + }, + ); + } +} + +export class CloudflareImagesTerminalUpstreamProblem extends Problem { + constructor(context: CloudflareImagesErrorContext) { + super( + "storage-cloudflare/terminal-upstream", + ProblemCategory.InternalServerError, + `Cloudflare Images upstream request failed terminally during ${context.operation}`, + { + extensions: { + ...context, + retryable: false, + }, + }, + ); + } +} + +export class CloudflareImagesDiagnosticsProvider implements DiagnosticsProvider { + readonly name = "storage-cloudflare"; + + constructor( + private readonly config: Partial, + private readonly options: CloudflareImagesDiagnosticsOptions = {}, + ) {} + + async getHealth(signal?: AbortSignal): Promise { + const baseDetails = this.createSafeConfigDetails(); + let validConfig: CloudflareImagesOptions; + + try { + validConfig = validateCloudflareImagesOptions(this.config); + } catch (error) { + const problem = + error instanceof Problem + ? error + : normalizeCloudflareImagesError(error, { operation: "configuration" }); + + return { + status: "unhealthy", + component: this.name, + message: problem.detail, + details: { + ...baseDetails, + liveCheck: "not_started", + problemCode: problem.code, + problemStatus: problem.status, + }, + lastChecked: new Date().toISOString(), + }; + } + + if (!this.options.readinessCheck) { + return { + status: "healthy", + component: this.name, + message: + "Cloudflare Images configuration is present; live upstream readiness check is not configured", + details: { + ...baseDetails, + liveCheck: "not_configured", + }, + lastChecked: new Date().toISOString(), + }; + } + + try { + const result = await this.options.readinessCheck({ config: validConfig, signal }); + + return { + status: "healthy", + component: this.name, + message: result?.message ?? "Cloudflare Images readiness check passed", + details: { + ...baseDetails, + liveCheck: "passed", + ...(result?.details && { readiness: sanitizeDiagnosticValue(result.details) }), + }, + lastChecked: new Date().toISOString(), + }; + } catch (error) { + const problem = normalizeCloudflareImagesError(error, { operation: "readiness" }); + + return { + status: "degraded", + component: this.name, + message: problem.detail, + details: { + ...baseDetails, + liveCheck: "failed", + problemCode: problem.code, + problemStatus: problem.status, + }, + lastChecked: new Date().toISOString(), + }; + } + } + + private createSafeConfigDetails(): Record { + return { + provider: "cloudflare-images", + hasAccountId: isNonEmptyString(this.config.accountId), + hasApiToken: isNonEmptyString(this.config.apiToken), + hasAccountHash: isNonEmptyString(this.config.accountHash), + hasSigningKey: isNonEmptyString(this.config.signingKey), + hasCustomDomain: isNonEmptyString(this.config.customDomain), + defaultVariant: this.config.defaultVariant ?? "public", + metadataSupport: { + contentType: "unsupported", + customMetadata: "unsupported", + }, + }; + } +} + +export function validateCloudflareImagesOptions( + config: Partial, +): CloudflareImagesOptions { + if (!isNonEmptyString(config.accountId)) { + throw new CloudflareImagesMissingConfigProblem("accountId"); + } + + if (!isNonEmptyString(config.apiToken)) { + throw new CloudflareImagesMissingConfigProblem("apiToken"); + } + + if (!isNonEmptyString(config.accountHash)) { + throw new CloudflareImagesMissingConfigProblem("accountHash"); + } + + validatePositiveInteger(config.ttl, "ttl"); + validatePositiveInteger(config.maxUploadBytes, "maxUploadBytes"); + + return config as CloudflareImagesOptions; +} + +export function normalizeCloudflareImagesError( + error: unknown, + options: { + readonly operation: string; + readonly key?: string; + readonly status?: number; + readonly upstreamCode?: string; + }, +): Problem { + if (error instanceof Problem) { + return error; + } + + const context = createCloudflareImagesErrorContext(error, options); + + if (isValidationError(context)) { + return new CloudflareImagesValidationProblem(context); + } + + if (isRetryableUpstreamError(context)) { + return new CloudflareImagesRetryableUpstreamProblem(context); + } + + return new CloudflareImagesTerminalUpstreamProblem(context); +} + +export function createCloudflareImagesResponseProblem(options: { + readonly operation: string; + readonly key?: string; + readonly status?: number; + readonly upstreamCode?: string; + readonly detail?: string; +}): Problem { + const context: CloudflareImagesErrorContext = { + provider: "cloudflare-images", + operation: options.operation, + ...(options.key !== undefined && { key: options.key }), + ...(options.status !== undefined && { status: options.status }), + ...(options.upstreamCode !== undefined && { upstreamCode: options.upstreamCode }), + }; + + if (isValidationError(context)) { + return new CloudflareImagesValidationProblem( + context, + options.detail ?? "Cloudflare Images request validation failed", + ); + } + + if (isRetryableUpstreamError(context)) { + return new CloudflareImagesRetryableUpstreamProblem(context); + } + + return new CloudflareImagesTerminalUpstreamProblem(context); +} + +function createCloudflareImagesErrorContext( + error: unknown, + options: { + readonly operation: string; + readonly key?: string; + readonly status?: number; + readonly upstreamCode?: string; + }, +): CloudflareImagesErrorContext { + const record = asRecord(error); + const status = firstNumber(options.status, record?.status, record?.statusCode, record?.http_code); + const upstreamCode = firstString(options.upstreamCode, record?.code, record?.name); + + return { + provider: "cloudflare-images", + operation: options.operation, + ...(options.key !== undefined && { key: options.key }), + ...(status !== undefined && { status }), + ...(upstreamCode !== undefined && { upstreamCode }), + }; +} + +function isValidationError(context: CloudflareImagesErrorContext): boolean { + return ( + context.status === 400 || + context.status === 401 || + context.status === 403 || + context.status === 422 || + context.upstreamCode === "validation-failed" || + context.upstreamCode === "missing-signing-key" + ); +} + +function isRetryableUpstreamError(context: CloudflareImagesErrorContext): boolean { + return ( + context.status === 408 || + context.status === 425 || + context.status === 429 || + (context.status !== undefined && context.status >= 500) || + context.upstreamCode === "ECONNRESET" || + context.upstreamCode === "ETIMEDOUT" || + context.upstreamCode === "UND_ERR_CONNECT_TIMEOUT" + ); +} + +function validatePositiveInteger(value: unknown, label: string): void { + if (value === undefined) { + return; + } + + if ( + typeof value !== "number" || + !Number.isFinite(value) || + !Number.isInteger(value) || + value <= 0 + ) { + throw new CloudflareImagesValidationProblem( + { + provider: "cloudflare-images", + operation: "configuration", + upstreamCode: `invalid-${label}`, + }, + `Cloudflare Images configuration '${label}' must be a positive finite integer`, + ); + } +} + +const SENSITIVE_DIAGNOSTIC_KEY = + /(authorization|password|secret|token|api[-_]?key|access[-_]?token|signing[-_]?key)/i; + +function sanitizeDiagnosticValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => sanitizeDiagnosticValue(item)); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (value instanceof Error) { + return { + name: value.name, + }; + } + + if (typeof value === "object" && value !== null) { + const sanitized: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + sanitized[key] = SENSITIVE_DIAGNOSTIC_KEY.test(key) + ? "[redacted]" + : sanitizeDiagnosticValue(nestedValue); + } + return sanitized; + } + + return value; +} + +function asRecord(value: unknown): Record | undefined { + if (typeof value === "object" && value !== null) { + return value as Record; + } + + return undefined; +} + +function firstNumber(...values: readonly unknown[]): number | undefined { + for (const value of values) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + + return undefined; +} + +function firstString(...values: readonly unknown[]): string | undefined { + for (const value of values) { + if (isNonEmptyString(value)) { + return value; + } + } + + return undefined; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} diff --git a/packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts b/packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts index 5e52d468c..0ed15c1a2 100644 --- a/packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts +++ b/packages/storage-cloudflare/src/libs/CloudflareImagesProvider.ts @@ -9,6 +9,10 @@ import type { UploadIntent, } from "@croco/storage-core"; import { BaseStorageProvider } from "@croco/storage-core"; +import { + createCloudflareImagesResponseProblem, + normalizeCloudflareImagesError, +} from "./CloudflareImagesDiagnosticsProvider"; import type { CloudflareImageDetails, CloudflareImagesOptions, @@ -98,23 +102,37 @@ export class CloudflareImagesProvider extends BaseStorageProvider implements Ima formData.append("file", file); - const response = await fetch(this.apiBaseUrl, { - method: "POST", - headers: { - Authorization: `Bearer ${this.options.apiToken}`, + const response = await this.fetchCloudflare(this.apiBaseUrl, { + init: { + method: "POST", + headers: { + Authorization: `Bearer ${this.options.apiToken}`, + }, + body: formData, }, - body: formData, + key, + operation: "put", }); if (!response.ok) { const errorText = await response.text(); - this.throwUploadFailed(key, `Cloudflare API error: ${errorText}`); + throw createCloudflareImagesResponseProblem({ + operation: "put", + key, + status: response.status, + detail: `Cloudflare Images API error: ${errorText}`, + }); } const result = (await response.json()) as CloudflareUploadResponse; if (!result.success) { - this.throwUploadFailed(key, `Cloudflare upload failed: ${result.errors.join(", ")}`); + throw createCloudflareImagesResponseProblem({ + operation: "put", + key, + upstreamCode: "validation-failed", + detail: `Cloudflare Images upload failed: ${result.errors.join(", ")}`, + }); } } @@ -122,14 +140,18 @@ export class CloudflareImagesProvider extends BaseStorageProvider implements Ima this.validateKey(key); const url = this.buildImageUrl(key, this.options.defaultVariant ?? "public"); - const response = await fetch(url); + const response = await this.fetchCloudflare(url, { key, operation: "get" }); if (!response.ok) { if (response.status === 404) { this.throwNotFound(key); } - this.throwUploadFailed(key, `Failed to fetch image: HTTP ${response.status}`); + throw createCloudflareImagesResponseProblem({ + operation: "get", + key, + status: response.status, + }); } const arrayBuffer = await response.arrayBuffer(); @@ -139,22 +161,36 @@ export class CloudflareImagesProvider extends BaseStorageProvider implements Ima async delete(key: string): Promise { this.validateKey(key); - const response = await fetch(`${this.apiBaseUrl}/${key}`, { - method: "DELETE", - headers: { - Authorization: `Bearer ${this.options.apiToken}`, + const response = await this.fetchCloudflare(`${this.apiBaseUrl}/${key}`, { + init: { + method: "DELETE", + headers: { + Authorization: `Bearer ${this.options.apiToken}`, + }, }, + key, + operation: "delete", }); if (!response.ok) { const errorText = await response.text(); - this.throwDeleteFailed(key, `Cloudflare delete error: ${errorText}`); + throw createCloudflareImagesResponseProblem({ + operation: "delete", + key, + status: response.status, + detail: `Cloudflare Images delete error: ${errorText}`, + }); } const result = await response.json(); if (!result.success) { - this.throwDeleteFailed(key, `Cloudflare delete failed: ${result.errors.join(", ")}`); + throw createCloudflareImagesResponseProblem({ + operation: "delete", + key, + upstreamCode: "validation-failed", + detail: `Cloudflare Images delete failed: ${result.errors.join(", ")}`, + }); } } @@ -181,10 +217,14 @@ export class CloudflareImagesProvider extends BaseStorageProvider implements Ima ): Promise<{ size: number; contentType?: string; lastModified: Date; etag?: string }> { this.validateKey(key); - const response = await fetch(`${this.apiBaseUrl}/${key}`, { - headers: { - Authorization: `Bearer ${this.options.apiToken}`, + const response = await this.fetchCloudflare(`${this.apiBaseUrl}/${key}`, { + init: { + headers: { + Authorization: `Bearer ${this.options.apiToken}`, + }, }, + key, + operation: "metadata", }); if (response.status === 404) { @@ -193,13 +233,23 @@ export class CloudflareImagesProvider extends BaseStorageProvider implements Ima if (!response.ok) { const errorText = await response.text(); - this.throwUploadFailed(key, `Cloudflare metadata error: ${errorText}`); + throw createCloudflareImagesResponseProblem({ + operation: "metadata", + key, + status: response.status, + detail: `Cloudflare Images metadata error: ${errorText}`, + }); } const result = (await response.json()) as CloudflareImageDetails; if (!result.success) { - this.throwUploadFailed(key, `Cloudflare metadata failed: ${result.errors.join(", ")}`); + throw createCloudflareImagesResponseProblem({ + operation: "metadata", + key, + upstreamCode: "validation-failed", + detail: `Cloudflare Images metadata failed: ${result.errors.join(", ")}`, + }); } if (!result.result) { @@ -238,29 +288,43 @@ export class CloudflareImagesProvider extends BaseStorageProvider implements Ima const url = `${this.apiBaseUrl}/direct_upload`; - const response = await fetch(url, { - method: "POST", - headers: { - Authorization: `Bearer ${this.options.apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - maxDurationSeconds: ttl, - metadata: { - originalKey: key, + const response = await this.fetchCloudflare(url, { + init: { + method: "POST", + headers: { + Authorization: `Bearer ${this.options.apiToken}`, + "Content-Type": "application/json", }, - }), + body: JSON.stringify({ + maxDurationSeconds: ttl, + metadata: { + originalKey: key, + }, + }), + }, + key, + operation: "upload-intent", }); if (!response.ok) { const errorText = await response.text(); - this.throwUploadFailed(key, `Cloudflare upload intent error: ${errorText}`); + throw createCloudflareImagesResponseProblem({ + operation: "upload-intent", + key, + status: response.status, + detail: `Cloudflare Images upload intent error: ${errorText}`, + }); } const result = await response.json(); if (!result.success) { - this.throwUploadFailed(key, `Cloudflare upload intent failed: ${result.errors.join(", ")}`); + throw createCloudflareImagesResponseProblem({ + operation: "upload-intent", + key, + upstreamCode: "validation-failed", + detail: `Cloudflare Images upload intent failed: ${result.errors.join(", ")}`, + }); } if (!result.result) { @@ -444,7 +508,12 @@ export class CloudflareImagesProvider extends BaseStorageProvider implements Ima private async getSigningKey(key: string): Promise { const { signingKey } = this.options; if (!signingKey) { - this.throwUploadFailed(key, "Cloudflare signingKey is required for signed URL generation"); + throw createCloudflareImagesResponseProblem({ + operation: "signed-url", + key, + upstreamCode: "missing-signing-key", + detail: "Cloudflare signingKey is required for signed URL generation", + }); } const keyData = new TextEncoder().encode(signingKey); @@ -453,4 +522,22 @@ export class CloudflareImagesProvider extends BaseStorageProvider implements Ima "sign", ]); } + + private async fetchCloudflare( + input: string, + options: { + readonly init?: RequestInit; + readonly key: string; + readonly operation: string; + }, + ): Promise { + try { + return options.init === undefined ? await fetch(input) : await fetch(input, options.init); + } catch (error) { + throw normalizeCloudflareImagesError(error, { + key: options.key, + operation: options.operation, + }); + } + } } diff --git a/packages/storage-cloudflare/src/tests/CloudflareImagesDiagnosticsProvider.spec.ts b/packages/storage-cloudflare/src/tests/CloudflareImagesDiagnosticsProvider.spec.ts new file mode 100644 index 000000000..1eb79f6c2 --- /dev/null +++ b/packages/storage-cloudflare/src/tests/CloudflareImagesDiagnosticsProvider.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { CloudflareImagesDiagnosticsProvider } from "../libs/CloudflareImagesDiagnosticsProvider"; + +const validConfig = { + accountHash: "test-account-hash", + accountId: "test-account-id", + apiToken: "test-api-token", + signingKey: "test-signing-key", +}; + +describe("CloudflareImagesDiagnosticsProvider", () => { + it("reports unhealthy readiness when required configuration is missing", async () => { + const provider = new CloudflareImagesDiagnosticsProvider({ + accountId: "test-account-id", + apiToken: "test-api-token", + }); + + const health = await provider.getHealth(); + + expect(health).toMatchObject({ + status: "unhealthy", + component: "storage-cloudflare", + details: { + liveCheck: "not_started", + problemCode: "storage-cloudflare/missing-config", + problemStatus: 500, + hasAccountId: true, + hasApiToken: true, + hasAccountHash: false, + }, + }); + }); + + it("reports healthy readiness without mutating upstream state when live check is not configured", async () => { + const provider = new CloudflareImagesDiagnosticsProvider(validConfig); + + const health = await provider.getHealth(); + + expect(health).toMatchObject({ + status: "healthy", + component: "storage-cloudflare", + details: { + liveCheck: "not_configured", + hasAccountId: true, + hasApiToken: true, + hasAccountHash: true, + hasSigningKey: true, + metadataSupport: { + contentType: "unsupported", + customMetadata: "unsupported", + }, + }, + }); + }); + + it("sanitizes readiness details returned by a live check", async () => { + const provider = new CloudflareImagesDiagnosticsProvider(validConfig, { + readinessCheck: async () => ({ + details: { + accountId: "test-account-id", + apiToken: "must-not-leak", + nested: { + signingKey: "must-not-leak", + }, + }, + }), + }); + + const health = await provider.getHealth(); + + expect(health).toMatchObject({ + status: "healthy", + details: { + liveCheck: "passed", + readiness: { + accountId: "test-account-id", + apiToken: "[redacted]", + nested: { + signingKey: "[redacted]", + }, + }, + }, + }); + }); + + it("normalizes failed live checks to deterministic provider Problem codes", async () => { + const provider = new CloudflareImagesDiagnosticsProvider(validConfig, { + readinessCheck: async () => { + throw Object.assign(new Error("Cloudflare API unavailable"), { status: 503 }); + }, + }); + + const health = await provider.getHealth(); + + expect(health).toMatchObject({ + status: "degraded", + component: "storage-cloudflare", + details: { + liveCheck: "failed", + problemCode: "storage-cloudflare/retryable-upstream", + problemStatus: 500, + }, + }); + }); +}); diff --git a/packages/storage-cloudflare/src/tests/CloudflareImagesLiveSmoke.spec.ts b/packages/storage-cloudflare/src/tests/CloudflareImagesLiveSmoke.spec.ts new file mode 100644 index 000000000..601939cc2 --- /dev/null +++ b/packages/storage-cloudflare/src/tests/CloudflareImagesLiveSmoke.spec.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { CloudflareImagesDiagnosticsProvider } from "../libs/CloudflareImagesDiagnosticsProvider"; +import type { CloudflareImagesOptions } from "../libs/types"; + +const CLOUDFLARE_IMAGES_LIVE_ENV = [ + "CROCO_LIVE_CLOUDFLARE_IMAGES", + "CLOUDFLARE_ACCOUNT_ID", + "CLOUDFLARE_API_TOKEN", + "CLOUDFLARE_ACCOUNT_HASH", +] as const; + +const liveConfig: CloudflareImagesOptions = { + accountHash: process.env.CLOUDFLARE_ACCOUNT_HASH ?? "", + accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "", + apiToken: process.env.CLOUDFLARE_API_TOKEN ?? "", +}; + +const missingLiveSmokeEnv = [ + ...(!isTruthyEnv("CROCO_LIVE_CLOUDFLARE_IMAGES") ? ["CROCO_LIVE_CLOUDFLARE_IMAGES"] : []), + ...CLOUDFLARE_IMAGES_LIVE_ENV.filter( + (name) => name !== "CROCO_LIVE_CLOUDFLARE_IMAGES" && !process.env[name], + ), +]; + +describe("Cloudflare Images live smoke", () => { + it.skipIf(missingLiveSmokeEnv.length > 0)( + "requires CROCO_LIVE_CLOUDFLARE_IMAGES and Cloudflare Images credentials for live readiness smoke", + async () => { + const provider = new CloudflareImagesDiagnosticsProvider(liveConfig, { + readinessCheck: async ({ config, signal }) => { + const response = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${config.accountId}/images/v1?per_page=1`, + { + headers: { + Authorization: `Bearer ${config.apiToken}`, + }, + signal, + }, + ); + + if (!response.ok) { + throw Object.assign(new Error(`Cloudflare Images readiness HTTP ${response.status}`), { + status: response.status, + }); + } + + const payload = (await response.json()) as { + readonly result_info?: { + readonly count?: number; + }; + readonly success?: boolean; + }; + + if (payload.success === false) { + throw Object.assign(new Error("Cloudflare Images readiness failed"), { + code: "validation-failed", + }); + } + + return { + details: { + accountId: config.accountId, + imageCount: payload.result_info?.count ?? 0, + }, + }; + }, + }); + + const health = await provider.getHealth(); + + expect(health).toMatchObject({ + status: "healthy", + component: "storage-cloudflare", + details: expect.objectContaining({ + liveCheck: "passed", + }), + }); + }, + ); +}); + +function isTruthyEnv(name: string): boolean { + const value = process.env[name]?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes"; +} diff --git a/packages/storage-cloudflare/src/tests/CloudflareImagesProvider.spec.ts b/packages/storage-cloudflare/src/tests/CloudflareImagesProvider.spec.ts index fed1625d7..a4ff297a8 100644 --- a/packages/storage-cloudflare/src/tests/CloudflareImagesProvider.spec.ts +++ b/packages/storage-cloudflare/src/tests/CloudflareImagesProvider.spec.ts @@ -1,5 +1,5 @@ import { Container } from "@croco/framework-context"; -import { DeleteFailedProblem, FileNotFoundProblem, UploadFailedProblem } from "@croco/storage-core"; +import { FileNotFoundProblem, UploadFailedProblem } from "@croco/storage-core"; import { createStorageProviderConformanceSuite } from "@croco/testing"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CloudflareImagesProvider } from "../libs/CloudflareImagesProvider"; @@ -162,7 +162,7 @@ describe("CloudflareImagesProvider", () => { expect(mockFetch).not.toHaveBeenCalled(); }); - it("should throw UploadFailedProblem when API returns error", async () => { + it("should throw terminal provider Problem when API returns error", async () => { const mockBuffer = Buffer.from("test-image-data"); const mockResponse = { ok: false, @@ -171,10 +171,12 @@ describe("CloudflareImagesProvider", () => { mockFetch.mockResolvedValueOnce(mockResponse); - await expect(provider.put("test.jpg", mockBuffer)).rejects.toThrow(UploadFailedProblem); + await expect(provider.put("test.jpg", mockBuffer)).rejects.toMatchObject({ + code: "storage-cloudflare/terminal-upstream", + }); }); - it("should throw UploadFailedProblem when response success is false", async () => { + it("should throw validation provider Problem when response success is false", async () => { const mockBuffer = Buffer.from("test-image-data"); const mockResponse = { ok: true, @@ -186,7 +188,9 @@ describe("CloudflareImagesProvider", () => { mockFetch.mockResolvedValueOnce(mockResponse); - await expect(provider.put("test.jpg", mockBuffer)).rejects.toThrow(UploadFailedProblem); + await expect(provider.put("test.jpg", mockBuffer)).rejects.toMatchObject({ + code: "storage-cloudflare/validation-failed", + }); }); }); @@ -283,7 +287,7 @@ describe("CloudflareImagesProvider", () => { ); }); - it("should throw DeleteFailedProblem when delete fails", async () => { + it("should throw terminal provider Problem when delete fails", async () => { const mockResponse = { ok: false, text: async () => "Not found", @@ -291,10 +295,12 @@ describe("CloudflareImagesProvider", () => { mockFetch.mockResolvedValueOnce(mockResponse); - await expect(provider.delete("test-image-id")).rejects.toThrow(DeleteFailedProblem); + await expect(provider.delete("test-image-id")).rejects.toMatchObject({ + code: "storage-cloudflare/terminal-upstream", + }); }); - it("should throw DeleteFailedProblem when response success is false", async () => { + it("should throw validation provider Problem when response success is false", async () => { const mockResponse = { ok: true, json: async () => ({ @@ -305,7 +311,9 @@ describe("CloudflareImagesProvider", () => { mockFetch.mockResolvedValueOnce(mockResponse); - await expect(provider.delete("test-image-id")).rejects.toThrow(DeleteFailedProblem); + await expect(provider.delete("test-image-id")).rejects.toMatchObject({ + code: "storage-cloudflare/validation-failed", + }); }); }); @@ -342,7 +350,9 @@ describe("CloudflareImagesProvider", () => { mockFetch.mockResolvedValueOnce(mockResponse); - await expect(provider.exists("test-image-id")).rejects.toThrow(UploadFailedProblem); + await expect(provider.exists("test-image-id")).rejects.toMatchObject({ + code: "storage-cloudflare/retryable-upstream", + }); }); }); @@ -416,7 +426,7 @@ describe("CloudflareImagesProvider", () => { expect(url).toContain("cdn.example.com"); }); - it("should throw UploadFailedProblem when signingKey is missing", async () => { + it("should throw validation provider Problem when signingKey is missing", async () => { const providerWithoutSigningKey = new CloudflareImagesProvider({ accountId: "test-account-id", apiToken: "test-api-token", @@ -426,7 +436,9 @@ describe("CloudflareImagesProvider", () => { await expect( providerWithoutSigningKey.getSignedUrl("test-image-id", { expiresIn: 3600 }), - ).rejects.toThrow(UploadFailedProblem); + ).rejects.toMatchObject({ + code: "storage-cloudflare/validation-failed", + }); }); }); @@ -469,7 +481,7 @@ describe("CloudflareImagesProvider", () => { await expect(provider.getMetadata("non-existent-id")).rejects.toThrow(FileNotFoundProblem); }); - it("should throw UploadFailedProblem when API returns error", async () => { + it("should throw terminal provider Problem when API returns error", async () => { const mockResponse = { ok: false, text: async () => "Unauthorized", @@ -477,7 +489,9 @@ describe("CloudflareImagesProvider", () => { mockFetch.mockResolvedValueOnce(mockResponse); - await expect(provider.getMetadata("test-image-id")).rejects.toThrow(UploadFailedProblem); + await expect(provider.getMetadata("test-image-id")).rejects.toMatchObject({ + code: "storage-cloudflare/terminal-upstream", + }); }); it("should handle missing size field", async () => { @@ -730,7 +744,7 @@ describe("CloudflareImagesProvider", () => { expect(mockFetch).not.toHaveBeenCalled(); }); - it("should throw UploadFailedProblem when API returns error", async () => { + it("should throw terminal provider Problem when API returns error", async () => { const mockResponse = { ok: false, text: async () => "Unauthorized", @@ -738,10 +752,12 @@ describe("CloudflareImagesProvider", () => { mockFetch.mockResolvedValueOnce(mockResponse); - await expect(provider.getUploadIntent("new-image.jpg")).rejects.toThrow(UploadFailedProblem); + await expect(provider.getUploadIntent("new-image.jpg")).rejects.toMatchObject({ + code: "storage-cloudflare/terminal-upstream", + }); }); - it("should throw UploadFailedProblem when response success is false", async () => { + it("should throw validation provider Problem when response success is false", async () => { const mockResponse = { ok: true, json: async () => ({ @@ -752,7 +768,9 @@ describe("CloudflareImagesProvider", () => { mockFetch.mockResolvedValueOnce(mockResponse); - await expect(provider.getUploadIntent("new-image.jpg")).rejects.toThrow(UploadFailedProblem); + await expect(provider.getUploadIntent("new-image.jpg")).rejects.toMatchObject({ + code: "storage-cloudflare/validation-failed", + }); }); it("should use custom domain for publicUrl when configured", async () => { diff --git a/packages/storage-cloudflare/tsconfig.json b/packages/storage-cloudflare/tsconfig.json index a39dbd865..d785efdeb 100644 --- a/packages/storage-cloudflare/tsconfig.json +++ b/packages/storage-cloudflare/tsconfig.json @@ -1,5 +1,11 @@ { "exclude": ["node_modules"], "extends": "../../tsconfig/tsconfig.node.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@croco/testing": ["../testing/src/libs/provider-conformance.ts"] + } + }, "include": ["src/**/*.ts"] } diff --git a/packages/storage-cloudflare/vitest.config.ts b/packages/storage-cloudflare/vitest.config.ts index 50fa2037c..54a326520 100644 --- a/packages/storage-cloudflare/vitest.config.ts +++ b/packages/storage-cloudflare/vitest.config.ts @@ -7,8 +7,12 @@ const currentDir = dirname(fileURLToPath(import.meta.url)); export default defineConfig({ resolve: { alias: { + "@croco/diagnostics-core": resolve(currentDir, "../diagnostics-core/src/index.ts"), + "@croco/framework-context": resolve(currentDir, "../framework-context/src/index.ts"), "@croco/problems-core": resolve(currentDir, "../problems-core/src/index.ts"), "@croco/storage-core": resolve(currentDir, "../storage-core/src/index.ts"), + "@croco/telemetry-api": resolve(currentDir, "../telemetry-api/src/index.ts"), + "@croco/testing": resolve(currentDir, "../testing/src/libs/provider-conformance.ts"), }, }, test: { diff --git a/packages/storage-cloudinary/README.md b/packages/storage-cloudinary/README.md index 24eac5879..4f523b390 100644 --- a/packages/storage-cloudinary/README.md +++ b/packages/storage-cloudinary/README.md @@ -46,16 +46,61 @@ const transformed = provider.getTransformUrl("uploads/hero.jpg", { ## API 레퍼런스 -| API | 설명 | -| ---------------------------- | --------------------------------------------------------------------- | -| `CloudinaryProvider` | 업로드, 다운로드, 삭제, 메타데이터 조회와 변환 URL 생성을 담당합니다. | -| `CLOUDINARY_CONFIG` | DI 등록용 토큰입니다. | -| `CloudinaryConfig` | 제공자 설정 타입입니다. | -| `CloudinaryUploadOptions` | 업로드 확장 옵션 타입입니다. | -| `CloudinaryTransformOptions` | 변환 파라미터 타입입니다. | +| API | 설명 | +| ----------------------------------- | ----------------------------------------------------------------------------- | +| `CloudinaryProvider` | 업로드, 다운로드, 삭제, 메타데이터 조회와 변환 URL 생성을 담당합니다. | +| `CloudinaryDiagnosticsProvider` | 안전한 설정 상태와 선택적 readiness check 결과를 `HealthStatus`로 노출합니다. | +| `CLOUDINARY_CONFIG` | DI 등록용 토큰입니다. | +| `validateCloudinaryConfig()` | 필수 설정과 양수 정수 옵션을 검증합니다. | +| `normalizeCloudinaryStorageError()` | Cloudinary/fetch 실패를 provider 전용 `Problem`으로 정규화합니다. | +| `CloudinaryConfig` | 제공자 설정 타입입니다. | +| `CloudinaryUploadOptions` | 업로드 확장 옵션 타입입니다. | +| `CloudinaryTransformOptions` | 변환 파라미터 타입입니다. | + +## 진단과 readiness + +```typescript +import { CloudinaryDiagnosticsProvider } from "@croco/storage-cloudinary"; + +const diagnostics = new CloudinaryDiagnosticsProvider({ + cloudName: process.env.CLOUDINARY_CLOUD_NAME, + apiKey: process.env.CLOUDINARY_API_KEY, + apiSecret: process.env.CLOUDINARY_API_SECRET, +}); + +const health = await diagnostics.getHealth(); +``` + +- 필수 설정이 빠지면 `unhealthy` 상태와 `storage-cloudinary/missing-config` 코드가 반환됩니다. +- `readinessCheck`를 넘기지 않으면 외부 API를 호출하지 않고 설정 존재 여부만 `healthy`로 보고합니다. +- `readinessCheck`가 실패하면 `degraded` 상태와 정규화된 provider Problem 코드가 반환됩니다. +- 진단 detail은 토큰, secret, authorization 값을 redaction 처리합니다. + +## 실패 코드 + +| 코드 | 의미 | +| --------------------------------------- | ------------------------------------------------------ | +| `storage-cloudinary/missing-config` | `cloudName`, `apiKey`, `apiSecret` 중 하나가 없음 | +| `storage-cloudinary/validation-failed` | 400, 401, 403, 422 응답 또는 잘못된 설정/입력 실패 | +| `storage-cloudinary/retryable-upstream` | 408, 425, 429, 5xx 또는 네트워크 계열 재시도 가능 실패 | +| `storage-cloudinary/terminal-upstream` | 재시도 대상으로 분류되지 않는 Cloudinary/fetch 실패 | + +## 선택적 live smoke + +기본 테스트는 실제 Cloudinary 자격 증명을 요구하지 않습니다. 실제 backend readiness를 확인하려면 아래 환경 변수를 모두 설정한 뒤 live smoke를 opt-in 합니다. + +```bash +CROCO_LIVE_CLOUDINARY=1 \ +CLOUDINARY_CLOUD_NAME=... \ +CLOUDINARY_API_KEY=... \ +CLOUDINARY_API_SECRET=... \ +pnpm --filter @croco/storage-cloudinary test -- CloudinaryLiveSmoke +``` ## 동작 메모 - `cover`, `contain`, `fill`, `inside`, `outside`를 Cloudinary crop 값으로 변환합니다. - 일시적 네트워크 오류와 5xx 응답은 최대 3회 재시도합니다. - 업로드 인텐트는 직접 업로드 엔드포인트 URL과 공개 URL을 함께 반환합니다. +- `StorageProvider`의 `list()` 계약은 아직 존재하지 않으므로 provider도 목록 조회를 제공하지 않습니다. +- custom metadata는 Cloudinary context로 보존됩니다. `getMetadata().contentType`은 원래 MIME 전체가 아니라 Cloudinary resource `format` 값입니다. diff --git a/packages/storage-cloudinary/package.json b/packages/storage-cloudinary/package.json index 299ec00cd..4701278be 100644 --- a/packages/storage-cloudinary/package.json +++ b/packages/storage-cloudinary/package.json @@ -40,11 +40,14 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@croco/diagnostics-core": "workspace:*", "@croco/framework-context": "workspace:*", "@croco/problems-core": "workspace:*", "@croco/retry-core": "workspace:*", "@croco/storage-core": "workspace:*", "cloudinary": "^2.10.0" }, - "devDependencies": {} + "devDependencies": { + "@croco/testing": "workspace:*" + } } diff --git a/packages/storage-cloudinary/src/index.ts b/packages/storage-cloudinary/src/index.ts index d23dbeb23..65deed056 100644 --- a/packages/storage-cloudinary/src/index.ts +++ b/packages/storage-cloudinary/src/index.ts @@ -2,6 +2,17 @@ * Cloudinary 기반 스토리지 및 이미지 변환 제공자 구현체입니다. */ export { CloudinaryProvider } from "./libs/CloudinaryProvider"; +export { + CloudinaryDiagnosticsProvider, + CloudinaryMissingConfigProblem, + CloudinaryRetryableUpstreamProblem, + CloudinaryTerminalUpstreamProblem, + CloudinaryValidationProblem, + getCloudinaryErrorMessage, + isRetryableCloudinaryStorageError, + normalizeCloudinaryStorageError, + validateCloudinaryConfig, +} from "./libs/CloudinaryDiagnosticsProvider"; /** * Cloudinary 설정을 DI 컨테이너에 등록할 때 사용하는 토큰입니다. @@ -11,6 +22,13 @@ export { CLOUDINARY_CONFIG } from "./libs/tokens"; /** * Cloudinary 제공자 구성과 확장 옵션에 필요한 공개 타입들입니다. */ +export type { + CloudinaryConfigKey, + CloudinaryDiagnosticsOptions, + CloudinaryReadinessCheckContext, + CloudinaryReadinessCheckResult, + CloudinaryStorageErrorContext, +} from "./libs/CloudinaryDiagnosticsProvider"; export type { CloudinaryConfig, CloudinaryTransformOptions, diff --git a/packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts b/packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts new file mode 100644 index 000000000..54acfd6ae --- /dev/null +++ b/packages/storage-cloudinary/src/libs/CloudinaryDiagnosticsProvider.ts @@ -0,0 +1,457 @@ +import type { DiagnosticsProvider, HealthStatus } from "@croco/diagnostics-core"; +import { Problem, ProblemCategory } from "@croco/problems-core"; +import type { CloudinaryConfig } from "./types"; + +const TRANSIENT_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]); +const TRANSIENT_ERROR_CODES = new Set([ + "ECONNABORTED", + "ECONNREFUSED", + "ECONNRESET", + "EAI_AGAIN", + "ENETDOWN", + "ENETRESET", + "ENETUNREACH", + "ENOTFOUND", + "ETIMEDOUT", + "UND_ERR_BODY_TIMEOUT", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_SOCKET", +]); + +export type CloudinaryStorageErrorContext = { + readonly provider: "cloudinary"; + readonly operation: string; + readonly key?: string; + readonly status?: number; + readonly upstreamCode?: string; + readonly retryable?: boolean; +}; + +export type CloudinaryReadinessCheckContext = { + readonly config: CloudinaryConfig; + readonly signal?: AbortSignal; +}; + +export type CloudinaryReadinessCheckResult = { + readonly message?: string; + readonly details?: Record; +}; + +export type CloudinaryDiagnosticsOptions = { + readonly readinessCheck?: ( + context: CloudinaryReadinessCheckContext, + ) => Promise; +}; + +export type CloudinaryConfigKey = "apiKey" | "apiSecret" | "cloudName"; + +export class CloudinaryMissingConfigProblem extends Problem { + constructor(configKey: CloudinaryConfigKey, operation = "configuration") { + super( + "storage-cloudinary/missing-config", + ProblemCategory.InternalServerError, + `Cloudinary configuration is missing required value '${configKey}'`, + { + extensions: { + provider: "cloudinary", + operation, + configKey, + }, + }, + ); + } +} + +export class CloudinaryValidationProblem extends Problem { + constructor( + context: CloudinaryStorageErrorContext, + detail = "Cloudinary storage request validation failed", + ) { + super( + "storage-cloudinary/validation-failed", + ProblemCategory.ValidationError, + `${detail} during ${context.operation}`, + { + extensions: context, + }, + ); + } +} + +export class CloudinaryRetryableUpstreamProblem extends Problem { + constructor(context: CloudinaryStorageErrorContext) { + super( + "storage-cloudinary/retryable-upstream", + ProblemCategory.InternalServerError, + `Cloudinary upstream request failed retryably during ${context.operation}`, + { + extensions: { + ...context, + retryable: true, + }, + }, + ); + } +} + +export class CloudinaryTerminalUpstreamProblem extends Problem { + constructor(context: CloudinaryStorageErrorContext) { + super( + "storage-cloudinary/terminal-upstream", + ProblemCategory.InternalServerError, + `Cloudinary upstream request failed terminally during ${context.operation}`, + { + extensions: { + ...context, + retryable: false, + }, + }, + ); + } +} + +export class CloudinaryDiagnosticsProvider implements DiagnosticsProvider { + readonly name = "storage-cloudinary"; + + constructor( + private readonly config: Partial, + private readonly options: CloudinaryDiagnosticsOptions = {}, + ) {} + + async getHealth(signal?: AbortSignal): Promise { + const baseDetails = this.createSafeConfigDetails(); + let validConfig: CloudinaryConfig; + + try { + validConfig = validateCloudinaryConfig(this.config); + } catch (error) { + const problem = + error instanceof Problem + ? error + : normalizeCloudinaryStorageError(error, { operation: "configuration" }); + + return { + status: "unhealthy", + component: this.name, + message: problem.detail, + details: { + ...baseDetails, + liveCheck: "not_started", + problemCode: problem.code, + problemStatus: problem.status, + }, + lastChecked: new Date().toISOString(), + }; + } + + if (!this.options.readinessCheck) { + return { + status: "healthy", + component: this.name, + message: + "Cloudinary configuration is present; live upstream readiness check is not configured", + details: { + ...baseDetails, + liveCheck: "not_configured", + }, + lastChecked: new Date().toISOString(), + }; + } + + try { + const result = await this.options.readinessCheck({ config: validConfig, signal }); + + return { + status: "healthy", + component: this.name, + message: result?.message ?? "Cloudinary readiness check passed", + details: { + ...baseDetails, + liveCheck: "passed", + ...(result?.details && { readiness: sanitizeDiagnosticValue(result.details) }), + }, + lastChecked: new Date().toISOString(), + }; + } catch (error) { + const problem = normalizeCloudinaryStorageError(error, { operation: "readiness" }); + + return { + status: "degraded", + component: this.name, + message: problem.detail, + details: { + ...baseDetails, + liveCheck: "failed", + problemCode: problem.code, + problemStatus: problem.status, + }, + lastChecked: new Date().toISOString(), + }; + } + } + + private createSafeConfigDetails(): Record { + return { + provider: "cloudinary", + hasCloudName: isNonEmptyString(this.config.cloudName), + hasApiKey: isNonEmptyString(this.config.apiKey), + hasApiSecret: isNonEmptyString(this.config.apiSecret), + secure: this.config.secure ?? true, + hasUploadBaseUrl: isNonEmptyString(this.config.uploadBaseUrl), + metadataSupport: { + contentType: "format-only", + customMetadata: "required", + }, + }; + } +} + +export function validateCloudinaryConfig(config: Partial): CloudinaryConfig { + if (!isNonEmptyString(config.cloudName)) { + throw new CloudinaryMissingConfigProblem("cloudName"); + } + + if (!isNonEmptyString(config.apiKey)) { + throw new CloudinaryMissingConfigProblem("apiKey"); + } + + if (!isNonEmptyString(config.apiSecret)) { + throw new CloudinaryMissingConfigProblem("apiSecret"); + } + + validatePositiveInteger(config.ttl, "ttl"); + + return config as CloudinaryConfig; +} + +export function normalizeCloudinaryStorageError( + error: unknown, + options: { + readonly operation: string; + readonly key?: string; + readonly status?: number; + readonly upstreamCode?: string; + }, +): Problem { + if (error instanceof Problem) { + return error; + } + + const context = createCloudinaryStorageErrorContext(error, options); + + if (isNotFoundError(context, error)) { + return new CloudinaryTerminalUpstreamProblem(context); + } + + if (isValidationError(context)) { + return new CloudinaryValidationProblem(context); + } + + if (isRetryableCloudinaryStorageError(error, context)) { + return new CloudinaryRetryableUpstreamProblem(context); + } + + return new CloudinaryTerminalUpstreamProblem(context); +} + +export function isRetryableCloudinaryStorageError( + error: unknown, + knownContext?: CloudinaryStorageErrorContext, +): boolean { + const context = + knownContext ?? createCloudinaryStorageErrorContext(error, { operation: "unknown" }); + + if (context.status !== undefined) { + if (context.status === 404) { + return false; + } + + if (TRANSIENT_HTTP_STATUSES.has(context.status)) { + return true; + } + } + + if (context.upstreamCode && TRANSIENT_ERROR_CODES.has(context.upstreamCode)) { + return true; + } + + const message = getCloudinaryErrorMessage(error, "").toLowerCase(); + + return [ + "connection reset", + "connect timeout", + "econnreset", + "fetch failed", + "network error", + "rate limit", + "socket hang up", + "temporarily unavailable", + "timed out", + "timeout", + "too many requests", + "try again", + ].some((pattern) => message.includes(pattern)); +} + +export function getCloudinaryErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message.length > 0) { + return error.message; + } + + const record = asRecord(error); + const message = record?.message; + if (typeof message === "string" && message.length > 0) { + return message; + } + + const nestedMessage = asRecord(record?.error)?.message; + if (typeof nestedMessage === "string" && nestedMessage.length > 0) { + return nestedMessage; + } + + return fallback; +} + +function createCloudinaryStorageErrorContext( + error: unknown, + options: { + readonly operation: string; + readonly key?: string; + readonly status?: number; + readonly upstreamCode?: string; + }, +): CloudinaryStorageErrorContext { + const record = asRecord(error); + const extensions = asRecord(record?.extensions); + const nestedError = asRecord(record?.error); + const status = firstNumber( + options.status, + record?.status, + record?.http_code, + record?.statusCode, + nestedError?.status, + nestedError?.http_code, + nestedError?.statusCode, + extensions?.status, + ); + const upstreamCode = firstString( + options.upstreamCode, + extensions?.upstreamCode, + record?.code, + record?.name, + nestedError?.code, + nestedError?.name, + typeof record?.error === "string" ? record.error : undefined, + nestedError?.message, + ); + + return { + provider: "cloudinary", + operation: options.operation, + ...(options.key !== undefined && { key: options.key }), + ...(status !== undefined && { status }), + ...(upstreamCode !== undefined && { upstreamCode }), + }; +} + +function isNotFoundError(context: CloudinaryStorageErrorContext, error: unknown): boolean { + return ( + context.status === 404 || + getCloudinaryErrorMessage(error, "").toLowerCase().includes("not found") + ); +} + +function isValidationError(context: CloudinaryStorageErrorContext): boolean { + return ( + context.status === 400 || + context.status === 401 || + context.status === 403 || + context.status === 422 + ); +} + +function validatePositiveInteger(value: unknown, label: string): void { + if (value === undefined) { + return; + } + + if ( + typeof value !== "number" || + !Number.isFinite(value) || + !Number.isInteger(value) || + value <= 0 + ) { + throw new CloudinaryValidationProblem( + { + provider: "cloudinary", + operation: "configuration", + upstreamCode: `invalid-${label}`, + }, + `Cloudinary configuration '${label}' must be a positive finite integer`, + ); + } +} + +const SENSITIVE_DIAGNOSTIC_KEY = + /(authorization|password|secret|token|api[-_]?key|access[-_]?token)/i; + +function sanitizeDiagnosticValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => sanitizeDiagnosticValue(item)); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (value instanceof Error) { + return { + name: value.name, + }; + } + + if (typeof value === "object" && value !== null) { + const sanitized: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + sanitized[key] = SENSITIVE_DIAGNOSTIC_KEY.test(key) + ? "[redacted]" + : sanitizeDiagnosticValue(nestedValue); + } + return sanitized; + } + + return value; +} + +function asRecord(value: unknown): Record | undefined { + if (typeof value === "object" && value !== null) { + return value as Record; + } + + return undefined; +} + +function firstNumber(...values: readonly unknown[]): number | undefined { + for (const value of values) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + + return undefined; +} + +function firstString(...values: readonly unknown[]): string | undefined { + for (const value of values) { + if (isNonEmptyString(value)) { + return value; + } + } + + return undefined; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} diff --git a/packages/storage-cloudinary/src/libs/CloudinaryProvider.ts b/packages/storage-cloudinary/src/libs/CloudinaryProvider.ts index bfb51be07..92de73998 100644 --- a/packages/storage-cloudinary/src/libs/CloudinaryProvider.ts +++ b/packages/storage-cloudinary/src/libs/CloudinaryProvider.ts @@ -13,143 +13,92 @@ import type { } from "@croco/storage-core"; import { BaseStorageProvider } from "@croco/storage-core"; import { v2 as cloudinary } from "cloudinary"; +import { + CloudinaryTerminalUpstreamProblem, + getCloudinaryErrorMessage, + isRetryableCloudinaryStorageError, + normalizeCloudinaryStorageError, +} from "./CloudinaryDiagnosticsProvider"; import type { CloudinaryConfig, CloudinaryTransformOptions } from "./types"; -const TRANSIENT_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]); -const TRANSIENT_ERROR_CODES = new Set([ - "ECONNABORTED", - "ECONNREFUSED", - "ECONNRESET", - "EAI_AGAIN", - "ENETDOWN", - "ENETRESET", - "ENETUNREACH", - "ENOTFOUND", - "ETIMEDOUT", - "UND_ERR_BODY_TIMEOUT", - "UND_ERR_CONNECT_TIMEOUT", - "UND_ERR_HEADERS_TIMEOUT", - "UND_ERR_SOCKET", -]); const CLOUDINARY_RETRY_POLICY: RetryPolicy = { shouldRetry(error: unknown, attempt: number, maxAttempts: number) { if (attempt >= maxAttempts) { return false; } - return isRetryableCloudinaryError(error); + return isRetryableCloudinaryStorageError(error); }, }; -function getErrorStatus(error: unknown): number | undefined { - if (typeof error !== "object" || error === null) { - return undefined; - } - - const status = Reflect.get(error, "status"); - if (typeof status === "number") { - return status; - } - - const httpCode = Reflect.get(error, "http_code"); - if (typeof httpCode === "number") { - return httpCode; - } - - const statusCode = Reflect.get(error, "statusCode"); - if (typeof statusCode === "number") { - return statusCode; - } - - return undefined; -} - -function getErrorCode(error: unknown): string | undefined { - if (typeof error !== "object" || error === null || !("code" in error)) { - return undefined; - } - - const code = Reflect.get(error, "code"); - return typeof code === "string" ? code : undefined; -} - -function getErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - - if (typeof error === "object" && error !== null && "message" in error) { - const message = Reflect.get(error, "message"); - if (typeof message === "string") { - return message; - } - } - - return ""; -} - -function isRetryableCloudinaryError(error: unknown): boolean { - const status = getErrorStatus(error); - if (status !== undefined) { - if (status === 404) { - return false; - } - - if (TRANSIENT_HTTP_STATUSES.has(status)) { - return true; - } - } - - const code = getErrorCode(error); - if (code && TRANSIENT_ERROR_CODES.has(code)) { - return true; - } - - const message = getErrorMessage(error).toLowerCase(); - - return [ - "connection reset", - "connect timeout", - "econnreset", - "fetch failed", - "network error", - "rate limit", - "socket hang up", - "temporarily unavailable", - "timed out", - "timeout", - "too many requests", - "try again", - ].some((pattern) => message.includes(pattern)); -} - -type CloudinaryError = Error & { +type CloudinarySdkError = Error & { code?: string; http_code?: number; status?: number; statusCode?: number; }; -function normalizeCloudinaryError(error: unknown, fallbackMessage: string): CloudinaryError { +function toCloudinarySdkError(error: unknown, fallbackMessage: string): CloudinarySdkError { if (error instanceof Error) { - return error as CloudinaryError; + return error as CloudinarySdkError; + } + + const sdkError = new Error( + getCloudinaryErrorMessage(error, fallbackMessage), + ) as CloudinarySdkError; + const record = + typeof error === "object" && error !== null ? (error as Record) : undefined; + const nestedError = + typeof record?.error === "object" && record.error !== null + ? (record.error as Record) + : undefined; + + const code = firstString( + record?.code, + record?.name, + nestedError?.code, + nestedError?.name, + typeof record?.error === "string" ? record.error : undefined, + ); + if (code !== undefined) { + sdkError.code = code; + } + + const status = firstNumber( + record?.http_code, + record?.status, + record?.statusCode, + nestedError?.http_code, + nestedError?.status, + nestedError?.statusCode, + ); + if (status !== undefined) { + sdkError.http_code = status; + sdkError.status = status; + sdkError.statusCode = status; } - const normalizedError = new Error(getErrorMessage(error) || fallbackMessage) as CloudinaryError; - const code = getErrorCode(error); - const status = getErrorStatus(error); + return sdkError; +} - if (code) { - normalizedError.code = code; +function firstNumber(...values: readonly unknown[]): number | undefined { + for (const value of values) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } } - if (status !== undefined) { - normalizedError.status = status; - normalizedError.statusCode = status; - normalizedError.http_code = status; + return undefined; +} + +function firstString(...values: readonly unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.length > 0) { + return value; + } } - return normalizedError; + return undefined; } /** @@ -208,9 +157,9 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv try { uploadStream = cloudinary.uploader.upload_stream( uploadOptions, - (error: Error | undefined, _result: unknown) => { + (error: unknown, _result: unknown) => { if (error) { - reject(normalizeCloudinaryError(error, "Unknown upload error")); + reject(toCloudinarySdkError(error, "Unknown Cloudinary upload error")); return; } @@ -218,7 +167,7 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv }, ); } catch (error) { - reject(normalizeCloudinaryError(error, "Unknown upload error")); + reject(toCloudinarySdkError(error, "Unknown Cloudinary upload error")); return; } @@ -239,7 +188,7 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv const uploadPromise = Buffer.isBuffer(data) ? this.executeWithRetry(upload) : upload(); return uploadPromise.catch((error) => { - this.throwUploadFailed(key, this.getErrorMessage(error, "Unknown upload error")); + throw normalizeCloudinaryStorageError(error, { key, operation: "put" }); }); } @@ -257,14 +206,13 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv this.throwNotFound(key); } - if (TRANSIENT_HTTP_STATUSES.has(fetchedResponse.status)) { - throw this.createHttpError( - fetchedResponse.status, - `Failed to fetch file: HTTP ${fetchedResponse.status}`, - ); - } - - this.throwUploadFailed(key, `Failed to fetch file: HTTP ${fetchedResponse.status}`); + throw normalizeCloudinaryStorageError( + { + message: `Failed to fetch file: HTTP ${fetchedResponse.status}`, + status: fetchedResponse.status, + }, + { key, operation: "get", status: fetchedResponse.status }, + ); } return fetchedResponse; @@ -273,15 +221,7 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv const arrayBuffer = await response.arrayBuffer(); return Buffer.from(arrayBuffer); } catch (error) { - if ( - error && - typeof error === "object" && - "code" in error && - error.code === "STORAGE_FILE_NOT_FOUND" - ) { - throw error; - } - this.throwUploadFailed(key, this.getErrorMessage(error, "Unknown error")); + throw normalizeCloudinaryStorageError(error, { key, operation: "get" }); } } @@ -296,23 +236,19 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv await cloudinary.uploader.destroy(key, { resource_type: "image", }), - "Unknown delete error", ), ); if (result.result !== "ok" && result.result !== "not found") { - this.throwDeleteFailed(key, `Delete failed: ${result.result}`); + throw new CloudinaryTerminalUpstreamProblem({ + provider: "cloudinary", + operation: "delete", + key, + upstreamCode: result.result, + }); } } catch (error) { - if ( - error && - typeof error === "object" && - "code" in error && - error.code === "STORAGE_DELETE_FAILED" - ) { - throw error; - } - this.throwDeleteFailed(key, error instanceof Error ? error.message : "Unknown error"); + throw normalizeCloudinaryStorageError(error, { key, operation: "delete" }); } } @@ -349,7 +285,6 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv await cloudinary.api.resource(key, { resource_type: "image", }), - "Unknown metadata error", ), )) as { bytes?: number; @@ -371,7 +306,7 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv this.throwNotFound(key); } - this.throwUploadFailed(key, this.getErrorMessage(error, "Unknown metadata error")); + throw normalizeCloudinaryStorageError(error, { key, operation: "metadata" }); } } @@ -422,20 +357,11 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv return await this.retryTemplate.execute(async () => await operation()); } - private createHttpError(status: number, message: string): Error & { status: number } { - const error = new Error(message) as Error & { status: number }; - error.status = status; - return error; - } - - private async executeCloudinaryOperation( - operation: () => Promise, - fallbackMessage: string, - ): Promise { + private async executeCloudinaryOperation(operation: () => Promise): Promise { try { return await this.withConfiguredCloudinary(operation); } catch (error) { - throw normalizeCloudinaryError(error, fallbackMessage); + throw toCloudinarySdkError(error, "Unknown Cloudinary error"); } } @@ -605,27 +531,19 @@ export class CloudinaryProvider extends BaseStorageProvider implements ImageProv } private isNotFoundError(error: unknown): boolean { - const status = getErrorStatus(error); - if (status === 404) { + if (typeof error === "object" && error !== null && Reflect.get(error, "http_code") === 404) { return true; } - const message = this.getErrorMessage(error, "").toLowerCase(); - return message.includes("not found"); - } - - private getErrorMessage(error: unknown, fallback: string): string { - if (error instanceof Error) { - return error.message; + if (typeof error === "object" && error !== null && Reflect.get(error, "status") === 404) { + return true; } - if (typeof error === "object" && error !== null && "message" in error) { - const message = Reflect.get(error, "message"); - if (typeof message === "string" && message.length > 0) { - return message; - } + if (typeof error === "object" && error !== null && Reflect.get(error, "statusCode") === 404) { + return true; } - return fallback; + const message = getCloudinaryErrorMessage(error, "").toLowerCase(); + return message.includes("not found"); } } diff --git a/packages/storage-cloudinary/src/tests/CloudinaryDiagnosticsProvider.spec.ts b/packages/storage-cloudinary/src/tests/CloudinaryDiagnosticsProvider.spec.ts new file mode 100644 index 000000000..cb0348503 --- /dev/null +++ b/packages/storage-cloudinary/src/tests/CloudinaryDiagnosticsProvider.spec.ts @@ -0,0 +1,142 @@ +import { v2 as cloudinary } from "cloudinary"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + CloudinaryDiagnosticsProvider, + getCloudinaryErrorMessage, + normalizeCloudinaryStorageError, +} from "../libs/CloudinaryDiagnosticsProvider"; + +const validConfig = { + apiKey: "test-api-key", + apiSecret: "test-api-secret", + cloudName: "test-cloud", + secure: true, +}; + +describe("CloudinaryDiagnosticsProvider", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reports unhealthy readiness when required configuration is missing", async () => { + const provider = new CloudinaryDiagnosticsProvider({ + apiKey: "test-api-key", + apiSecret: "test-api-secret", + }); + + const health = await provider.getHealth(); + + expect(health).toMatchObject({ + status: "unhealthy", + component: "storage-cloudinary", + details: { + liveCheck: "not_started", + problemCode: "storage-cloudinary/missing-config", + problemStatus: 500, + hasCloudName: false, + hasApiKey: true, + hasApiSecret: true, + }, + }); + }); + + it("reports healthy readiness without mutating global Cloudinary config when live check is not configured", async () => { + const configSpy = vi.spyOn(cloudinary, "config"); + const provider = new CloudinaryDiagnosticsProvider(validConfig); + + const health = await provider.getHealth(); + + expect(configSpy).not.toHaveBeenCalled(); + expect(health).toMatchObject({ + status: "healthy", + component: "storage-cloudinary", + details: { + liveCheck: "not_configured", + hasCloudName: true, + hasApiKey: true, + hasApiSecret: true, + metadataSupport: { + contentType: "format-only", + customMetadata: "required", + }, + }, + }); + }); + + it("sanitizes readiness details returned by a live check", async () => { + const provider = new CloudinaryDiagnosticsProvider(validConfig, { + readinessCheck: async () => ({ + details: { + cloudName: "test-cloud", + apiKey: "must-not-leak", + nested: { + apiSecret: "must-not-leak", + }, + }, + }), + }); + + const health = await provider.getHealth(); + + expect(health).toMatchObject({ + status: "healthy", + details: { + liveCheck: "passed", + readiness: { + cloudName: "test-cloud", + apiKey: "[redacted]", + nested: { + apiSecret: "[redacted]", + }, + }, + }, + }); + }); + + it("normalizes failed live checks to deterministic provider Problem codes", async () => { + const provider = new CloudinaryDiagnosticsProvider(validConfig, { + readinessCheck: async () => { + throw { http_code: 403, message: "Forbidden" }; + }, + }); + + const health = await provider.getHealth(); + + expect(health).toMatchObject({ + status: "degraded", + component: "storage-cloudinary", + details: { + liveCheck: "failed", + problemCode: "storage-cloudinary/validation-failed", + problemStatus: 422, + }, + }); + }); + + it("normalizes nested Cloudinary error payloads", () => { + const problem = normalizeCloudinaryStorageError( + { + error: { + code: "RATE_LIMITED", + http_code: 429, + message: "Too many requests", + }, + }, + { operation: "readiness" }, + ); + + expect(getCloudinaryErrorMessage({ error: { message: "Too many requests" } }, "fallback")).toBe( + "Too many requests", + ); + expect(problem).toMatchObject({ + code: "storage-cloudinary/retryable-upstream", + extensions: { + provider: "cloudinary", + operation: "readiness", + status: 429, + upstreamCode: "RATE_LIMITED", + retryable: true, + }, + }); + }); +}); diff --git a/packages/storage-cloudinary/src/tests/CloudinaryLiveSmoke.spec.ts b/packages/storage-cloudinary/src/tests/CloudinaryLiveSmoke.spec.ts new file mode 100644 index 000000000..9526b7c20 --- /dev/null +++ b/packages/storage-cloudinary/src/tests/CloudinaryLiveSmoke.spec.ts @@ -0,0 +1,63 @@ +import { v2 as cloudinary } from "cloudinary"; +import { describe, expect, it } from "vitest"; +import { CloudinaryDiagnosticsProvider } from "../libs/CloudinaryDiagnosticsProvider"; +import type { CloudinaryConfig } from "../libs/types"; + +const CLOUDINARY_LIVE_ENV = [ + "CROCO_LIVE_CLOUDINARY", + "CLOUDINARY_CLOUD_NAME", + "CLOUDINARY_API_KEY", + "CLOUDINARY_API_SECRET", +] as const; + +const liveConfig: CloudinaryConfig = { + apiKey: process.env.CLOUDINARY_API_KEY ?? "", + apiSecret: process.env.CLOUDINARY_API_SECRET ?? "", + cloudName: process.env.CLOUDINARY_CLOUD_NAME ?? "", + secure: true, +}; + +const missingLiveSmokeEnv = [ + ...(!isTruthyEnv("CROCO_LIVE_CLOUDINARY") ? ["CROCO_LIVE_CLOUDINARY"] : []), + ...CLOUDINARY_LIVE_ENV.filter((name) => name !== "CROCO_LIVE_CLOUDINARY" && !process.env[name]), +]; + +describe("Cloudinary live smoke", () => { + it.skipIf(missingLiveSmokeEnv.length > 0)( + "requires CROCO_LIVE_CLOUDINARY and Cloudinary credentials for live readiness smoke", + async () => { + const provider = new CloudinaryDiagnosticsProvider(liveConfig, { + readinessCheck: async ({ config }) => { + const response = await cloudinary.api.config({ + api_key: config.apiKey, + api_secret: config.apiSecret, + cloud_name: config.cloudName, + secure: config.secure, + settings: false, + }); + + return { + details: { + cloudName: response.cloud_name ?? config.cloudName, + }, + }; + }, + }); + + const health = await provider.getHealth(); + + expect(health).toMatchObject({ + status: "healthy", + component: "storage-cloudinary", + details: expect.objectContaining({ + liveCheck: "passed", + }), + }); + }, + ); +}); + +function isTruthyEnv(name: string): boolean { + const value = process.env[name]?.trim().toLowerCase(); + return value === "1" || value === "true" || value === "yes"; +} diff --git a/packages/storage-cloudinary/src/tests/CloudinaryProvider.spec.ts b/packages/storage-cloudinary/src/tests/CloudinaryProvider.spec.ts index d5b158b96..3e530302d 100644 --- a/packages/storage-cloudinary/src/tests/CloudinaryProvider.spec.ts +++ b/packages/storage-cloudinary/src/tests/CloudinaryProvider.spec.ts @@ -1,3 +1,4 @@ +import { PassThrough } from "node:stream"; import { Container } from "@croco/framework-context"; import type { ObjectMetadata, @@ -6,17 +7,19 @@ import type { TransformOptions, UploadIntent, } from "@croco/storage-core"; -import { - DeleteFailedProblem, - FileNotFoundProblem, - InvalidKeyProblem, - UploadFailedProblem, -} from "@croco/storage-core"; +import { FileNotFoundProblem, InvalidKeyProblem } from "@croco/storage-core"; +import { createStorageProviderConformanceSuite } from "@croco/testing"; import { v2 as cloudinary } from "cloudinary"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { CloudinaryProvider } from "../libs/CloudinaryProvider"; type UploadStream = typeof cloudinary.uploader.upload_stream; +type StoredCloudinaryObject = { + readonly context?: string; + readonly createdAt: string; + readonly data: Buffer; + readonly etag: string; +}; // Cloudinary SDK 모킹 vi.mock("cloudinary", () => ({ @@ -53,6 +56,27 @@ describe("CloudinaryProvider", () => { provider = new CloudinaryProvider(mockConfig); }); + describe("storage provider conformance", () => { + it.each( + createStorageProviderConformanceSuite({ + createProvider: () => { + useInMemoryCloudinaryBackend(); + return provider; + }, + keyPrefix: "cloudinary-conformance", + metadata: { + contentType: "unsupported", + customMetadata: "required", + }, + providerName: "storage-cloudinary", + publicUrl: "https://res.cloudinary.com/test-cloud/image/upload/", + signedUrl: /s=mock-signature/, + }).cases, + )("$name", async ({ run }) => { + await run(); + }); + }); + it("should not mutate global cloudinary config during construction", () => { expect(cloudinary.config).not.toHaveBeenCalled(); }); @@ -242,7 +266,7 @@ describe("CloudinaryProvider", () => { ); }); - it("should throw UploadFailedProblem on upload error", async () => { + it("should throw terminal provider Problem on upload error", async () => { const mockError = new Error("Upload failed"); const mockUploadStream = vi.fn( (_options: unknown, callback: (error: Error | undefined, result: unknown) => void) => { @@ -259,7 +283,9 @@ describe("CloudinaryProvider", () => { const buffer = Buffer.from("test data"); - await expect(provider.put("test-key", buffer)).rejects.toThrow(UploadFailedProblem); + await expect(provider.put("test-key", buffer)).rejects.toMatchObject({ + code: "storage-cloudinary/terminal-upstream", + }); }); it("should retry transient upload errors before succeeding", async () => { @@ -317,17 +343,17 @@ describe("CloudinaryProvider", () => { expect(cloudinary.uploader.upload_stream).toHaveBeenCalledTimes(3); }); - it("should throw UploadFailedProblem when upload stream creation throws", async () => { + it("should throw terminal provider Problem when upload stream creation throws", async () => { vi.mocked(cloudinary.uploader.upload_stream).mockImplementation(() => { throw new Error("Cloudinary SDK error"); }); - await expect(provider.put("test-key", Buffer.from("test data"))).rejects.toThrow( - UploadFailedProblem, - ); + await expect(provider.put("test-key", Buffer.from("test data"))).rejects.toMatchObject({ + code: "storage-cloudinary/terminal-upstream", + }); }); - it("should throw UploadFailedProblem when source stream emits error", async () => { + it("should throw terminal provider Problem when source stream emits error", async () => { const { PassThrough } = await import("node:stream"); const destination = new PassThrough(); @@ -340,7 +366,9 @@ describe("CloudinaryProvider", () => { source.emit("error", new Error("Stream broken")); - await expect(putPromise).rejects.toThrow(UploadFailedProblem); + await expect(putPromise).rejects.toMatchObject({ + code: "storage-cloudinary/terminal-upstream", + }); }); it("should not retry readable stream uploads on transient callback errors", async () => { @@ -367,7 +395,9 @@ describe("CloudinaryProvider", () => { await expect( provider.put("test-key", Readable.from(Buffer.from("test data"))), - ).rejects.toThrow(UploadFailedProblem); + ).rejects.toMatchObject({ + code: "storage-cloudinary/retryable-upstream", + }); expect(cloudinary.uploader.upload_stream).toHaveBeenCalledTimes(1); }); @@ -424,7 +454,7 @@ describe("CloudinaryProvider", () => { await expect(provider.get("test-key")).rejects.toThrow(FileNotFoundProblem); }); - it("should throw UploadFailedProblem on non-404 HTTP error", async () => { + it("should throw retryable provider Problem on retryable HTTP error", async () => { const mockResponse = { ok: false, status: 500, @@ -432,13 +462,17 @@ describe("CloudinaryProvider", () => { vi.mocked(global.fetch).mockResolvedValue(mockResponse as unknown as Response); - await expect(provider.get("test-key")).rejects.toThrow(UploadFailedProblem); + await expect(provider.get("test-key")).rejects.toMatchObject({ + code: "storage-cloudinary/retryable-upstream", + }); }); - it("should throw UploadFailedProblem on fetch error", async () => { + it("should throw retryable provider Problem on fetch error", async () => { vi.mocked(global.fetch).mockRejectedValue(new Error("Network error")); - await expect(provider.get("test-key")).rejects.toThrow(UploadFailedProblem); + await expect(provider.get("test-key")).rejects.toMatchObject({ + code: "storage-cloudinary/retryable-upstream", + }); }); it("should retry transient fetch failures before succeeding", async () => { @@ -500,10 +534,12 @@ describe("CloudinaryProvider", () => { await expect(provider.delete("test-key")).resolves.not.toThrow(); }); - it("should throw DeleteFailedProblem on delete failure", async () => { + it("should throw terminal provider Problem on delete failure", async () => { vi.mocked(cloudinary.uploader.destroy).mockResolvedValue({ result: "error" }); - await expect(provider.delete("test-key")).rejects.toThrow(DeleteFailedProblem); + await expect(provider.delete("test-key")).rejects.toMatchObject({ + code: "storage-cloudinary/terminal-upstream", + }); }); it("should retry transient delete failures before succeeding", async () => { @@ -683,13 +719,15 @@ describe("CloudinaryProvider", () => { await expect(provider.getMetadata("test-key")).rejects.toThrow(FileNotFoundProblem); }); - it("should throw UploadFailedProblem for non-404 metadata errors", async () => { + it("should throw validation provider Problem for non-404 metadata validation errors", async () => { vi.mocked(cloudinary.api.resource).mockRejectedValue({ http_code: 403, message: "Forbidden", }); - await expect(provider.getMetadata("test-key")).rejects.toThrow(UploadFailedProblem); + await expect(provider.getMetadata("test-key")).rejects.toMatchObject({ + code: "storage-cloudinary/validation-failed", + }); }); it("should retry transient metadata failures before succeeding", async () => { @@ -1091,3 +1129,114 @@ describe("CloudinaryProvider", () => { }); }); }); + +function useInMemoryCloudinaryBackend(): void { + const objects = new Map(); + + vi.mocked(cloudinary.uploader.upload_stream).mockImplementation((( + options: unknown, + callback: (error: Error | undefined, result: unknown) => void, + ) => { + const uploadOptions = options as { + readonly context?: string; + readonly public_id?: string; + }; + const destination = new PassThrough(); + const chunks: Buffer[] = []; + + destination.on("data", (chunk: Buffer | Uint8Array | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + destination.on("error", (error) => callback(error, undefined)); + destination.on("finish", () => { + if (!uploadOptions.public_id) { + callback(new Error("public_id is required"), undefined); + return; + } + + objects.set(uploadOptions.public_id, { + context: uploadOptions.context, + createdAt: "2026-01-01T00:00:00Z", + data: Buffer.concat(chunks), + etag: `${uploadOptions.public_id}:etag`, + }); + callback(undefined, { public_id: uploadOptions.public_id }); + }); + + return destination; + }) as unknown as UploadStream); + + vi.mocked(cloudinary.uploader.destroy).mockImplementation(async (key: string) => { + const existed = objects.delete(key); + return { result: existed ? "ok" : "not found" }; + }); + + vi.mocked(cloudinary.api.resource).mockImplementation(async (key: string) => { + const object = objects.get(key); + if (!object) { + throw { http_code: 404, message: "Resource not found" }; + } + + return { + bytes: object.data.length, + context: object.context, + created_at: object.createdAt, + etag: object.etag, + }; + }); + + vi.mocked(cloudinary.url).mockImplementation((key: string, options?: unknown) => { + const optionRecord = typeof options === "object" && options !== null ? options : undefined; + const cloudNameValue = optionRecord ? Reflect.get(optionRecord, "cloud_name") : undefined; + const secureValue = optionRecord ? Reflect.get(optionRecord, "secure") : undefined; + const transformationValue = optionRecord + ? Reflect.get(optionRecord, "transformation") + : undefined; + const signUrlValue = optionRecord ? Reflect.get(optionRecord, "sign_url") : undefined; + const cloudName = typeof cloudNameValue === "string" ? cloudNameValue : "test-cloud"; + const protocol = secureValue === false ? "http" : "https"; + const transformation = + typeof transformationValue === "string" && transformationValue.length > 0 + ? `${transformationValue}/` + : ""; + const query = signUrlValue ? "?expires=60&s=mock-signature" : ""; + + return `${protocol}://res.cloudinary.com/${cloudName}/image/upload/${transformation}${key}${query}`; + }); + + vi.mocked(global.fetch).mockImplementation(async (input: string | URL | Request) => { + const key = parseCloudinaryDeliveryKey(String(input), "test-cloud"); + if (!key) { + return new Response("Not found", { status: 404 }); + } + + const object = objects.get(key); + if (!object) { + return new Response("Not found", { status: 404 }); + } + + return new Response(new Uint8Array(object.data)); + }); +} + +function parseCloudinaryDeliveryKey(url: string, cloudName: string): string | null { + const parsed = new URL(url); + const marker = `/${cloudName}/image/upload/`; + const markerIndex = parsed.pathname.indexOf(marker); + + if (markerIndex === -1) { + return null; + } + + const rawKey = parsed.pathname.slice(markerIndex + marker.length); + if (rawKey.length === 0) { + return null; + } + + const segments = rawKey.split("/"); + if (segments[0]?.includes("_") && segments.length > 1) { + segments.shift(); + } + + return segments.map(decodeURIComponent).join("/"); +} diff --git a/packages/storage-cloudinary/tsconfig.json b/packages/storage-cloudinary/tsconfig.json index a39dbd865..d785efdeb 100644 --- a/packages/storage-cloudinary/tsconfig.json +++ b/packages/storage-cloudinary/tsconfig.json @@ -1,5 +1,11 @@ { "exclude": ["node_modules"], "extends": "../../tsconfig/tsconfig.node.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@croco/testing": ["../testing/src/libs/provider-conformance.ts"] + } + }, "include": ["src/**/*.ts"] } diff --git a/packages/storage-cloudinary/vitest.config.ts b/packages/storage-cloudinary/vitest.config.ts index 12445dcb0..854bab5e2 100644 --- a/packages/storage-cloudinary/vitest.config.ts +++ b/packages/storage-cloudinary/vitest.config.ts @@ -1,6 +1,21 @@ +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { defineConfig } from "vitest/config"; +const currentDir = dirname(fileURLToPath(import.meta.url)); + export default defineConfig({ + resolve: { + alias: { + "@croco/diagnostics-core": resolve(currentDir, "../diagnostics-core/src/index.ts"), + "@croco/framework-context": resolve(currentDir, "../framework-context/src/index.ts"), + "@croco/problems-core": resolve(currentDir, "../problems-core/src/index.ts"), + "@croco/retry-core": resolve(currentDir, "../retry-core/src/index.ts"), + "@croco/storage-core": resolve(currentDir, "../storage-core/src/index.ts"), + "@croco/telemetry-api": resolve(currentDir, "../telemetry-api/src/index.ts"), + "@croco/testing": resolve(currentDir, "../testing/src/libs/provider-conformance.ts"), + }, + }, test: { globals: true, environment: "node", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc49951e9..d6c57e769 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1041,6 +1041,12 @@ importers: '@croco/search-core': specifier: workspace:* version: link:../search-core + '@croco/storage-cloudflare': + specifier: workspace:* + version: link:../storage-cloudflare + '@croco/storage-cloudinary': + specifier: workspace:* + version: link:../storage-cloudinary '@croco/storage-core': specifier: workspace:* version: link:../storage-core @@ -2530,6 +2536,9 @@ importers: packages/storage-cloudflare: dependencies: + '@croco/diagnostics-core': + specifier: workspace:* + version: link:../diagnostics-core '@croco/framework-context': specifier: workspace:* version: link:../framework-context @@ -2546,6 +2555,9 @@ importers: packages/storage-cloudinary: dependencies: + '@croco/diagnostics-core': + specifier: workspace:* + version: link:../diagnostics-core '@croco/framework-context': specifier: workspace:* version: link:../framework-context @@ -2561,6 +2573,10 @@ importers: cloudinary: specifier: ^2.10.0 version: 2.10.0 + devDependencies: + '@croco/testing': + specifier: workspace:* + version: link:../testing packages/storage-core: dependencies: diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json index e3b8f6f3a..5181614be 100644 --- a/public-api-surface.snapshot.json +++ b/public-api-surface.snapshot.json @@ -15122,11 +15122,59 @@ "source": "./libs/tokens", "declarationKind": "const" }, + { + "name": "CloudflareImagesDiagnosticsProvider", + "exportKind": "named", + "source": "./libs/CloudflareImagesDiagnosticsProvider", + "declarationKind": "class" + }, + { + "name": "CloudflareImagesMissingConfigProblem", + "exportKind": "named", + "source": "./libs/CloudflareImagesDiagnosticsProvider", + "declarationKind": "class" + }, { "name": "CloudflareImagesProvider", "exportKind": "named", "source": "./libs/CloudflareImagesProvider", "declarationKind": "class" + }, + { + "name": "CloudflareImagesRetryableUpstreamProblem", + "exportKind": "named", + "source": "./libs/CloudflareImagesDiagnosticsProvider", + "declarationKind": "class" + }, + { + "name": "CloudflareImagesTerminalUpstreamProblem", + "exportKind": "named", + "source": "./libs/CloudflareImagesDiagnosticsProvider", + "declarationKind": "class" + }, + { + "name": "CloudflareImagesValidationProblem", + "exportKind": "named", + "source": "./libs/CloudflareImagesDiagnosticsProvider", + "declarationKind": "class" + }, + { + "name": "createCloudflareImagesResponseProblem", + "exportKind": "named", + "source": "./libs/CloudflareImagesDiagnosticsProvider", + "declarationKind": "function" + }, + { + "name": "normalizeCloudflareImagesError", + "exportKind": "named", + "source": "./libs/CloudflareImagesDiagnosticsProvider", + "declarationKind": "function" + }, + { + "name": "validateCloudflareImagesOptions", + "exportKind": "named", + "source": "./libs/CloudflareImagesDiagnosticsProvider", + "declarationKind": "function" } ], "typeExports": [ @@ -15135,11 +15183,36 @@ "exportKind": "named", "source": "./libs/types" }, + { + "name": "CloudflareImagesConfigKey", + "exportKind": "named", + "source": "./libs/CloudflareImagesDiagnosticsProvider" + }, + { + "name": "CloudflareImagesDiagnosticsOptions", + "exportKind": "named", + "source": "./libs/CloudflareImagesDiagnosticsProvider" + }, + { + "name": "CloudflareImagesErrorContext", + "exportKind": "named", + "source": "./libs/CloudflareImagesDiagnosticsProvider" + }, { "name": "CloudflareImagesOptions", "exportKind": "named", "source": "./libs/types" }, + { + "name": "CloudflareImagesReadinessCheckContext", + "exportKind": "named", + "source": "./libs/CloudflareImagesDiagnosticsProvider" + }, + { + "name": "CloudflareImagesReadinessCheckResult", + "exportKind": "named", + "source": "./libs/CloudflareImagesDiagnosticsProvider" + }, { "name": "CloudflareTransformOptions", "exportKind": "named", @@ -15163,11 +15236,65 @@ "source": "./libs/tokens", "declarationKind": "const" }, + { + "name": "CloudinaryDiagnosticsProvider", + "exportKind": "named", + "source": "./libs/CloudinaryDiagnosticsProvider", + "declarationKind": "class" + }, + { + "name": "CloudinaryMissingConfigProblem", + "exportKind": "named", + "source": "./libs/CloudinaryDiagnosticsProvider", + "declarationKind": "class" + }, { "name": "CloudinaryProvider", "exportKind": "named", "source": "./libs/CloudinaryProvider", "declarationKind": "class" + }, + { + "name": "CloudinaryRetryableUpstreamProblem", + "exportKind": "named", + "source": "./libs/CloudinaryDiagnosticsProvider", + "declarationKind": "class" + }, + { + "name": "CloudinaryTerminalUpstreamProblem", + "exportKind": "named", + "source": "./libs/CloudinaryDiagnosticsProvider", + "declarationKind": "class" + }, + { + "name": "CloudinaryValidationProblem", + "exportKind": "named", + "source": "./libs/CloudinaryDiagnosticsProvider", + "declarationKind": "class" + }, + { + "name": "getCloudinaryErrorMessage", + "exportKind": "named", + "source": "./libs/CloudinaryDiagnosticsProvider", + "declarationKind": "function" + }, + { + "name": "isRetryableCloudinaryStorageError", + "exportKind": "named", + "source": "./libs/CloudinaryDiagnosticsProvider", + "declarationKind": "function" + }, + { + "name": "normalizeCloudinaryStorageError", + "exportKind": "named", + "source": "./libs/CloudinaryDiagnosticsProvider", + "declarationKind": "function" + }, + { + "name": "validateCloudinaryConfig", + "exportKind": "named", + "source": "./libs/CloudinaryDiagnosticsProvider", + "declarationKind": "function" } ], "typeExports": [ @@ -15176,6 +15303,31 @@ "exportKind": "named", "source": "./libs/types" }, + { + "name": "CloudinaryConfigKey", + "exportKind": "named", + "source": "./libs/CloudinaryDiagnosticsProvider" + }, + { + "name": "CloudinaryDiagnosticsOptions", + "exportKind": "named", + "source": "./libs/CloudinaryDiagnosticsProvider" + }, + { + "name": "CloudinaryReadinessCheckContext", + "exportKind": "named", + "source": "./libs/CloudinaryDiagnosticsProvider" + }, + { + "name": "CloudinaryReadinessCheckResult", + "exportKind": "named", + "source": "./libs/CloudinaryDiagnosticsProvider" + }, + { + "name": "CloudinaryStorageErrorContext", + "exportKind": "named", + "source": "./libs/CloudinaryDiagnosticsProvider" + }, { "name": "CloudinaryTransformOptions", "exportKind": "named", diff --git a/scripts/package-docs-check.mts b/scripts/package-docs-check.mts index d5997523b..6426d15c3 100644 --- a/scripts/package-docs-check.mts +++ b/scripts/package-docs-check.mts @@ -1357,7 +1357,7 @@ function generateReadmeCatalog(state: CatalogState): string { "", "Adapter 경계와 공식 우선순위, compatibility certification checklist는 [Adapter Ecosystem](packages/docs/src/content/docs/en/reference/adapter-ecosystem.md)에 정의되어 있습니다. 성숙도 승급 기준은 [Provider Maturity Gates](packages/docs/src/content/docs/en/reference/provider-maturity.md)와 [Presentation Runtime Support](packages/docs/src/content/docs/en/reference/presentation-runtime-support.md)에 정의되어 있으며, package test 존재 여부만으로 production-ready나 certified compatibility를 의미하지 않습니다.", "", - "| 상태 | 의미 | 패키지 수 |", + "| 상태 | 의미 | 전체 public 패키지 수 |", "| --- | --- | ---: |", );