# oolio

> A universal API client library

Latest version **0.3.0** (published 2026-09-24) · MIT license · 0 weekly downloads

## Install

```sh
npm install oolio
pnpm add oolio
yarn add oolio
bun add oolio
```

## Health

**Score 70/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; recently updated; high maintenance score; high quality score.

Warnings: low downloads; pre 1.0.

## Facts

| | |
|---|---|
| Version | 0.3.0 |
| Published | 2026-09-24 |
| First published | 2025-04-27 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Node | >=14.0.0 |
| Dependencies | 0 |
| Unpacked size | 147.8 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Author | oollab |
| Maintainers | oollab |
| Keywords | api, client, http, request, rest, fetch, axios |

## Links

- npm: https://www.npmjs.com/package/oolio
- npm.io page: https://npm.io/package/oolio

## Alternatives

- [launchdarkly-js-client-sdk](https://npm.io/package/launchdarkly-js-client-sdk.md) — 2.5M weekly downloads
- [@elastic/elasticsearch](https://npm.io/package/@elastic/elasticsearch.md) — 2.1M weekly downloads
- [@c8y/client](https://npm.io/package/@c8y/client.md) — 15.3K weekly downloads
- [@signaldb/maverickjs](https://npm.io/package/@signaldb/maverickjs.md) — 1.7K weekly downloads
- [@bbc/http-transport-cache](https://npm.io/package/@bbc/http-transport-cache.md) — 1.2K weekly downloads

## Recent versions

- 0.3.0 (latest) — 2026-09-24
- 0.2.9 — 2026-06-28
- 0.2.8 — 2026-06-28
- 0.2.7 — 2026-05-18
- 0.2.6 — 2026-05-17
- 0.2.5 — 2026-05-17
- 0.2.4 — 2026-05-07
- 0.2.3 — 2026-05-06
- 0.2.2 — 2026-05-03
- 0.2.1 — 2026-05-03
- 0.2.0 — 2025-06-28
- 0.1.6 — 2025-04-27
- 0.1.5 — 2025-04-27
- 0.1.4 — 2025-04-27
- 0.1.3 — 2025-04-27
- … 3 more at https://npm.io/package/oolio/versions

## README

# oolio

범용 API 클라이언트 라이브러리입니다. RESTful API를 쉽게 호출할 수 있도록 도와줍니다.

## 설치

```bash
npm install oolio
```

## 특징

- 라우트 기반의 API 클라이언트
- 자동 인증 토큰 처리 (Bearer)
- 경로 파라미터 지원 (`/user/{userId}`, `/user/:userId` 모두 가능)
- 본문 자동 직렬화 — 일반 POST/PUT/DELETE는 `application/json`, `payload` 값에 `File`/`Blob`, React Native 파일 객체(`{ uri, type, name? }`), Node.js `Buffer`가 있으면 `multipart/form-data`로 자동 전환
- 통일된 에러 처리 형식 — `Error`를 상속한 `OolioError` (`status`, `data`, `url`, `method`)
- 401 자동 토큰 갱신 (`onUnauthorized`) — 동시 401은 한 번만 갱신
- 모든 옵션을 전역·라우트 양쪽에서 지정 (라우트 > 전역 > 기본값)
- 브라우저 / Node.js / React Native 환경 모두 지원
- TypeScript 제네릭으로 요청/응답 타입 정의 가능
- `fetchOptions`로 fetch init 옵션 주입 (`credentials`, `mode`, `cache`, `signal` 등) — 쿠키 인증 지원

## 사용법

### JavaScript

```javascript
import oolio from "oolio";

const routes = {
  auth: {
    login: {
      method: "post",
      path: "/auth/login",
      payload: ["email", "password"],
    },
  },
  user: {
    getProfile: {
      method: "get",
      path: "/user/profile",
    },
    getUserById: {
      method: "get",
      path: "/user/{userId}",
    },
    updateUserById: {
      method: "put",
      path: "/user/{userId}",
      payload: ["name", "email"],
    },
    uploadAvatar: {
      method: "post",
      path: "/user/avatar",
      payload: ["userId", "avatar"],
    },
  },
};

const api = oolio({
  routes,
  getAuthorizeToken: () => localStorage.getItem("token"),
  baseUrl: "https://api.example.com",
});

// 일반 요청
const response = await api.auth.login({
  email: "test@example.com",
  password: "1234",
});

// 경로 파라미터
const user = await api.user.getUserById({ userId: "123" });

// 경로 파라미터 + payload
await api.user.updateUserById(
  { userId: "123" },
  { name: "John", email: "john@example.com" },
);

// 파일 업로드
await api.user.uploadAvatar({ userId: "123", avatar: fileInput.files[0] });
```

### TypeScript

`IO<TPayload, TResponse>` 제네릭으로 요청/응답 타입을 정의할 수 있습니다.

```typescript
import oolio from "oolio";
import type { IO } from "oolio";

const routes = {
  auth: {
    login: {
      method: "post",
      path: "/auth/login",
      payload: ["email", "password"],
    } as IO<{ email: string; password: string }, { token: string }>,
  },
  user: {
    getProfile: {
      method: "get",
      path: "/user/profile",
    } as IO<void, { name: string; avatar: string }>,

    getUserById: {
      method: "get",
      path: "/user/{userId}",
    } as IO<void, { id: string; name: string }>,

    updateUserById: {
      method: "put",
      path: "/user/{userId}",
      payload: ["name", "email"],
    } as IO<{ name: string; email: string }, { success: boolean }>,

    uploadAvatar: {
      method: "post",
      path: "/user/avatar",
      payload: ["avatar"],
    } as IO<{ avatar: File }, { url: string }>,
  },
};

const api = oolio({
  routes,
  getAuthorizeToken: () => localStorage.getItem("token"),
  baseUrl: "https://api.example.com",
});

// 타입 자동 추론
const { token } = await api.auth.login({
  email: "test@example.com",
  password: "1234",
});
const { name } = await api.user.getProfile();
const { id } = await api.user.getUserById({ userId: "123" });

// 경로 파라미터 + payload
await api.user.updateUserById(
  { userId: "123" },
  { name: "John", email: "john@example.com" },
);
```

> **`IO` 타입 규칙**: `IO<TPayload, TResponse>`의 `TPayload`는 body(POST/PUT 등) 또는 query string(GET) 필드 타입만 기술합니다. path의 `{param}`/`:param` 패턴으로 선언한 **path params는 `IO` 타입에 포함하지 않습니다.** path params는 항상 `Record<string, string>`으로 처리되며, 라이브러리가 런타임에 자동으로 분리합니다.
>
> ```typescript
> // ❌ 잘못된 예: path param을 TPayload에 포함
> updateUserById: {
>   method: "put",
>   path: "/user/{userId}",
>   payload: ["name", "email"],
> } as IO<{ userId: string; name: string; email: string }, { success: boolean }>,
> //         ^^^^^^^^ path param — IO에 넣으면 안 됨
>
> // ✅ 올바른 예: payload 필드(body에 담길 것들)만 기술
> updateUserById: {
>   method: "put",
>   path: "/user/{userId}",
>   payload: ["name", "email"],
> } as IO<{ name: string; email: string }, { success: boolean }>,
>
> // ✅ path params만 있고 payload 없는 경우: TPayload는 void
> getUserById: {
>   method: "get",
>   path: "/user/{userId}",
> } as IO<void, { id: string; name: string }>,
> ```

## 라우트 옵션

| 옵션             | 필수 | 설명                                                                   |
| ---------------- | ---- | ---------------------------------------------------------------------- |
| `method`         | O    | HTTP 메소드 (get, post, put, delete, patch 등). 대소문자 무관 — 와이어에는 대문자로 정규화되어 전송 |
| `path`           | O    | API 엔드포인트 경로. 경로 파라미터는 `{param}` 또는 `:param` 형식      |
| `payload`        | -    | 요청에 포함될 데이터 필드 목록 (파일 필드도 여기에 함께 명시). **없으면 인자가 요청에 실리지 않음** |
| `authorization`  | -    | `false` 또는 `"guest"` 설정 시 토큰 미첨부. 미지정 시 전역 `authorization`, 그것도 없으면 true |
| `baseUrl`        | -    | 라우트별 baseUrl 오버라이드                                            |
| `option`         | -    | 라우트별 [클라이언트 옵션](#클라이언트-옵션-option). 전역 `option`보다 우선 |
| `fetchOptions`   | -    | 라우트별 fetch init 옵션. 전역보다 우선, 호출별보다는 후순위           |
| `onUnauthorized` | -    | 라우트별 [401 토큰 갱신](#401-토큰-갱신-onunauthorized) 핸들러. `false`면 이 라우트는 갱신 안 함 |

> ⚠️ **`payload`를 빠뜨리면 요청 본문(GET은 쿼리)이 비어서 나갑니다.** `payload`는 요청에 실을 키의 화이트리스트라, 선언하지 않은 키는 모두 잘려나갑니다. 인자를 넘겼는데 `payload`가 없거나 비어 있으면 라우트마다 한 번 콘솔 경고가 출력됩니다.
>
> ```
> [oolio] auth.login: 인자(email, password)를 받았지만 payload 선언이 없어 요청에 실리지 않습니다. 라우트에 payload: ["email", "password"]를 추가하세요.
> ```

> **경로 파라미터 형식**: `{param}`과 `:param`(Express·react-router 관례)을 모두 지원합니다. `:param`은 슬래시 바로 뒤에 올 때만 파라미터로 인식하므로 `/items:batchGet`처럼 경로 중간의 콜론은 그대로 둡니다. 치환되지 않은 파라미터가 남으면 요청 전에 오류를 던집니다.

> 파일 업로드는 별도 옵션 없이 `payload`에 키만 명시하면 됩니다. 호출 시 해당 값이 `File`/`Blob`, React Native 파일 객체(`{ uri: string, type: string, name?: string }`), Node.js `Buffer` 중 하나이면 자동으로 `multipart/form-data`로 전송됩니다.

> **메서드 대소문자**: `method`는 대소문자를 가리지 않습니다. GET 판정(`"get"`/`"GET"` 모두 인식)과 본문 직렬화 분기는 내부적으로 대소문자 무관하게 처리되고, 실제 fetch 호출에 넘기는 와이어 메서드는 대문자로 정규화됩니다(`"patch"` → `PATCH`).
>
> 특히 PATCH가 중요합니다 — fetch 스펙은 PATCH를 자동 대문자화 대상에서 제외하므로 `fetch(url, { method: "patch" })`는 소문자 `patch`가 그대로 와이어에 나가고, 일부 서버(예: Next 16 Node HTTP)는 이를 malformed로 간주해 빈 400으로 끊습니다. oolio는 이를 라이브러리 차원에서 방지하므로 소비측 routes는 메서드를 소문자로 둬도 안전합니다.

## 클라이언트 옵션 (`option`)

`oolio({ ..., option })`에 전달하는 클라이언트 단위 설정. 라우트에도 같은 모양의 `option`을 둘 수 있으며 라우트 값이 우선합니다 ([옵션 우선순위](#옵션-우선순위) 참고).

| 옵션                  | 기본값   | 설명                                                                           |
| --------------------- | -------- | ------------------------------------------------------------------------------ |
| `logger`              | false    | true 설정 시 모든 요청·응답·에러를 console에 출력                              |
| `loggerPretty`        | false    | true 설정 시 객체를 `JSON.stringify`로 전체 depth 출력 (`logger: true`일 때만 적용) |
| `errorMessageFullUrl` | false    | true 설정 시 `OolioError.message`에 쿼리를 포함한 전체 URL 사용 (기본은 쿼리 제외) |
| `arrayFormat`         | `"json"` | GET 쿼리의 배열 직렬화 방식 ([GET 쿼리 배열](#get-쿼리-배열-arrayformat) 참고) |

### 로그 활성화

```javascript
const api = oolio({
  routes,
  getAuthorizeToken: () => localStorage.getItem("token"),
  baseUrl: "https://api.example.com",
  option: { logger: true },
});
```

호출마다 6자 임시 ID가 발급되어 모든 로그 라인 prefix(`[oolio]:{id}`)에 포함되므로, 동시 호출 시에도 같은 요청의 로그를 ID로 묶어서 추적할 수 있습니다.

```
[oolio]:k3p9af → { method: 'post', path: '/auth/login', ... } { pathParams: {}, data: {...}, headers: { Authorization: 'Bearer e...XYZ12345' } }
[oolio]:k3p9af → POST https://api.example.com/auth/login
[oolio]:k3p9af ← POST https://api.example.com/auth/login (245ms) { token: '...' }
```

`Authorization` 헤더는 토큰 노출을 줄이기 위해 부분 마스킹됩니다(앞/뒤 일부만 표시). 그 외 body·data는 마스킹 없이 그대로 출력되므로 운영 환경에서는 활성화하지 않는 것을 권장합니다.

### 중첩 객체 전체 출력 (`loggerPretty`)

기본적으로 Node.js의 `console.log`는 객체를 depth 2까지만 출력해 중첩된 값이 `[Object]`로 잘립니다. `loggerPretty: true`를 함께 설정하면 객체를 `JSON.stringify`로 포매팅해 전체 내용을 확인할 수 있습니다.

```javascript
const api = oolio({
  routes,
  getAuthorizeToken: () => null,
  baseUrl: "https://api.example.com",
  option: { logger: true, loggerPretty: true },
});
```

```
// logger: true 만 설정한 경우
[oolio]:k3p9af ← GET https://api.example.com/items (120ms) { data: { items: [Array], total: 1 } }

// loggerPretty: true 추가 시
[oolio]:k3p9af ← GET https://api.example.com/items (120ms) {
  "data": {
    "items": [
      { "id": 1, "name": "example" }
    ],
    "total": 1
  }
}
```

## 본문 직렬화 규칙

axios의 동작과 유사하게, 메소드와 호출 시 데이터에 따라 자동으로 본문 형식이 결정됩니다.

| 조건                                                                        | Content-Type                          | 본문                          |
| --------------------------------------------------------------------------- | ------------------------------------- | ----------------------------- |
| `method: "get"` (대소문자 무관)                                             | (없음)                                | URL query string              |
| `payload` 값 중 `File`/`Blob` 인스턴스 존재                                 | `multipart/form-data` (자동)          | FormData (자동 변환)          |
| `payload` 값 중 RN 파일 객체 (`{ uri, type, name? }`) 존재                  | `multipart/form-data` (자동)          | FormData (자동 변환)          |
| `payload` 값 중 Node.js `Buffer` 존재                                       | `multipart/form-data` (자동)          | FormData (자동 변환)          |
| 호출 시 `data`로 `FormData` 인스턴스 직접 전달                              | `multipart/form-data` (자동)          | 전달한 FormData 그대로        |
| 그 외 POST/PUT/DELETE                                                       | `application/json`                    | `JSON.stringify(payload)`     |

- **사용자가 `headers["Content-Type"]`을 직접 지정한 경우 항상 그 값을 우선합니다** — 자동 분기로 multipart가 되는 경우에도 `delete`하지 않고 사용자가 지정한 값을 그대로 보냅니다 (단, `multipart/form-data`로 임의 지정 시 boundary는 사용자 책임).
- `payload`에 명시되지 않은 키는 자동 감지 대상에서 제외되어 잘려나갑니다 (협업 누락 방지를 위한 의도적 동작). `payload` 자체가 없으면 본문이 비므로 콘솔 경고가 출력됩니다.
- GET 쿼리가 비면 URL에 `?`를 붙이지 않습니다. `path`에 이미 쿼리가 있으면(`/search?type=a`) 추가 쿼리를 `&`로 이어 붙입니다.

### GET 쿼리 배열 (`arrayFormat`)

쿼리스트링에는 배열 표준이 없어 서버마다 기대하는 형식이 다릅니다. `option.arrayFormat`으로 고릅니다 (전역·라우트 모두 지정 가능).

| 값                 | 결과 (`{ ids: [1, 3] }`) | 쓰는 곳                                                   |
| ------------------ | ------------------------ | --------------------------------------------------------- |
| `"json"` (기본값)  | `ids=[1,3]`              | 기존 동작 호환용                                          |
| `"repeat"`         | `ids=1&ids=3`            | 브라우저 폼, Express·Fastify·Spring 등 대부분 서버의 기본 |
| `"bracket"`        | `ids[]=1&ids[]=3`        | PHP, qs                                                   |
| `"comma"`          | `ids=1,3`                | OpenAPI `explode=false`                                   |

```javascript
const api = oolio({ routes, getAuthorizeToken, baseUrl, option: { arrayFormat: "repeat" } });
```

배열이 아닌 객체 값은 형식과 무관하게 JSON 문자열로 보냅니다.

## fetch 옵션 (`fetchOptions`)

`oolio({ ..., fetchOptions })`에 전달하면 모든 요청의 `fetch(url, init)` 호출에 병합되는 init 옵션입니다. `credentials`, `mode`, `cache`, `signal`, `keepalive` 등 표준 `RequestInit` 필드를 지정할 수 있습니다.

쿠키 기반 인증(특히 cross-origin)에는 `credentials: "include"`가 필요합니다. 지정하지 않으면 fetch 기본값(`same-origin`)이 적용됩니다.

```javascript
const api = oolio({
  routes,
  getAuthorizeToken: () => localStorage.getItem("token"),
  baseUrl: "https://api.example.com",
  fetchOptions: { credentials: "include" }, // 모든 요청에 쿠키 동봉
});
```

- **라우트·호출별 오버라이드**: 라우트에 `fetchOptions`를 두거나, 마지막 인자로 `{ fetchOptions }`를 전달하면 해당 요청에 적용됩니다. 키 단위 얕은 병합이며 우선순위는 호출별 > 라우트 > 클라이언트입니다.
- **우선순위**: oolio가 관리하는 `method`/`body`/`headers`는 항상 최종 우선합니다. `fetchOptions.headers`를 지정해도 직렬화 단계에서 만든 헤더(`Authorization`, `Content-Type` 등)가 같은 키를 덮어씁니다.
- **인터셉터 노출**: 병합된 값은 `RequestConfig.fetchOptions`로 들어가 `request` 인터셉터에서 읽고 변형할 수 있습니다.

```javascript
// 이 호출만 same-origin으로 (클라이언트 기본 include를 덮어씀)
await api.user.getProfile({ fetchOptions: { credentials: "same-origin" } });

// pathParams + data + fetchOptions
await api.user.updateUserById(
  { userId: "123" },
  { name: "John", email: "john@example.com" },
  { fetchOptions: { cache: "no-store" } },
);

// headers와 함께 사용
await api.user.getProfile({
  headers: { "X-Trace-Id": "abc" },
  fetchOptions: { credentials: "include" },
});
```

## 인터셉터

`oolio({ ..., interceptors })`에 전달하는 훅. 한 번 등록하면 모든 요청에 자동 적용됩니다.

| 훅               | 시점                          | 시그니처                                                                                   |
| ---------------- | ----------------------------- | ------------------------------------------------------------------------------------------ |
| `request`        | fetch 직전 (직렬화 완료 후)   | `(config: RequestConfig) => RequestConfig \| Promise<RequestConfig>`                       |
| `response`       | 성공 응답 파싱 후             | `(data: any, config: RequestConfig) => any \| Promise<any>`                               |
| `responseError`  | 에러 최종 처리                | `(error: OolioError, config: RequestConfig) => any \| Promise<any>`                       |
| `retry`          | 에러 발생 시 재시도 여부 결정 | `(error: OolioError, config: RequestConfig, attempt: number) => boolean \| Promise<boolean>` |

- `attempt`: 지금까지 실패한 횟수. 첫 실패 후 호출 시 `attempt=1`.
- `retry`가 `true`를 반환하면 동일 config로 재시도합니다. `responseError`보다 먼저 실행되며, retry 포기 후 `responseError`로 넘어갑니다.
- 재시도 직전에 `getAuthorizeToken()`을 다시 읽어 `Authorization`을 갱신합니다. 따라서 `retry` 안에서 토큰을 갱신하고 `true`를 반환하면 새 토큰으로 나갑니다. 인터셉터에서 `Authorization`을 직접 바꿨다면 그 값을 유지합니다.
- 401 토큰 갱신은 전용 옵션 [`onUnauthorized`](#401-토큰-갱신-onunauthorized)를 권장합니다. `retry`보다 먼저 실행됩니다.
- `responseError`가 값을 반환하면 해당 값이 호출자에게 전달됩니다 (throw 없음). 직접 `throw`하면 호출자까지 전파됩니다.

```javascript
const api = oolio({
  routes,
  getAuthorizeToken: () => localStorage.getItem("token"),
  baseUrl: "https://api.example.com",
  interceptors: {
    // 모든 요청에 트레이스 ID 헤더 추가
    request: (config) => {
      config.headers["X-Trace-Id"] = crypto.randomUUID();
      return config;
    },
    // 응답 unwrap: { result: ... } 구조라면 result만 반환
    response: (data) => data.result ?? data,
    // 404는 null로 변환, 나머지는 그대로 throw
    responseError: async (err) => {
      if (err.status === 404) return null;
      throw err;
    },
    // 503 에러 최대 3회 지수 백오프 재시도
    retry: async (err, _config, attempt) => {
      if (err.status !== 503 || attempt >= 3) return false;
      await new Promise((r) => setTimeout(r, 2 ** attempt * 100));
      return true;
    },
  },
});
```

## per-request 옵션

각 API 호출의 **마지막 인자**로 `{ headers?: Record<string, string>; fetchOptions?: RequestInit }` 오브젝트를 전달하면 해당 요청에만 적용됩니다. 글로벌 인터셉터로 처리하기 어려운 요청별 헤더나 fetch 옵션에 사용합니다.

```javascript
// path params 없는 route — data 없이 headers만
api.user.getProfile({ headers: { "X-Request-Source": "mobile" } });

// path params 없는 route — data + headers
api.auth.login({ email: "a@b.com", password: "1234" }, { headers: { "X-Trace-Id": "abc" } });

// path params 있는 route — data 생략, pathParams + headers
api.user.getUserById({ userId: "123" }, { headers: { "X-Request-Source": "admin" } });

// path params 있는 route — pathParams + data + headers
api.user.updateUserById(
  { userId: "123" },
  { name: "John", email: "john@example.com" },
  { headers: { "X-Trace-Id": "abc" } },
);
```

`headers` 또는 `fetchOptions` 키를 가진 오브젝트를 마지막 인자로 넣으면 options로 인식합니다. data를 생략하고 싶다면 null 없이 바로 붙이면 됩니다.

```javascript
// path params 있는 route — data 생략
// { headers } 키가 있으므로 options로 자동 인식
api.user.getUserById({ userId: "123" }, { headers: { "X-Custom": "test" } });

// fetchOptions만 단독으로 넘겨도 options로 인식
api.user.getProfile({ fetchOptions: { credentials: "include" } });
```

> fetch 옵션 주입에 대한 자세한 내용은 [fetch 옵션 (`fetchOptions`)](#fetch-옵션-fetchoptions) 섹션을 참고하세요.

## 401 토큰 갱신 (`onUnauthorized`)

401 응답을 받으면 호출되는 토큰 갱신 핸들러입니다. `true`를 반환하면 `getAuthorizeToken()`을 다시 읽어 새 토큰으로 원래 요청을 한 번 재시도합니다.

```javascript
const api = oolio({
  routes,
  getAuthorizeToken: () => localStorage.getItem("token"),
  baseUrl: "https://api.example.com",
  onUnauthorized: async () => {
    const { accessToken } = await api.auth.refresh();
    localStorage.setItem("token", accessToken);
    return true; // false를 반환하거나 throw하면 원래 401 에러가 그대로 전달됨
  },
});
```

- **동시 401은 한 번만 갱신**합니다. 여러 요청이 동시에 401을 받아도 핸들러는 한 번 호출되고, 나머지는 결과를 기다렸다가 새 토큰으로 재시도합니다. refresh token rotation 서버에서 중복 갱신으로 로그아웃되는 문제를 막습니다.
- 요청당 **한 번만** 재시도합니다. 재시도 후에도 401이면 일반 에러 흐름(`retry` → `responseError` → throw)으로 넘어갑니다.
- `authorization`이 `false`/`"guest"`인 라우트, 라우트에 `onUnauthorized: false`를 둔 라우트는 대상에서 제외됩니다. **refresh 라우트는 `authorization: false`로 두는 것을 권장합니다.**
- 핸들러 안에서 부른 요청이 401을 받아도 교착되지 않습니다 (갱신 도중 시작된 요청은 갱신을 기다리지 않음).
- 요청이 나간 뒤 다른 요청이 이미 토큰을 갱신했다면, 핸들러를 다시 부르지 않고 새 토큰으로 바로 재시도합니다.
- `interceptors.retry`보다 먼저 실행됩니다. `retry`에도 401 갱신 로직을 두면 갱신이 중복될 수 있으니 한쪽만 사용하세요.
- 라우트에 `onUnauthorized`를 두면 전역 핸들러보다 우선합니다.

## 옵션 우선순위

모든 옵션은 전역(`oolio({...})`)과 라우트 양쪽에서 지정할 수 있습니다. 우선순위는 **호출별 > 라우트 > 전역 > 기본값**입니다.

| 옵션             | 전역                     | 라우트 | 호출별 |
| ---------------- | ------------------------ | ------ | ------ |
| `baseUrl`        | ○                        | ○      | -      |
| `authorization`  | ○                        | ○      | -      |
| `option`         | ○                        | ○      | -      |
| `fetchOptions`   | ○                        | ○      | ○      |
| `onUnauthorized` | ○                        | ○      | -      |

`option`·`fetchOptions`는 키 단위로 병합되며, 값이 `undefined`인 키는 하위 설정을 덮어쓰지 않습니다.

```javascript
const api = oolio({
  routes: {
    auth: {
      login: { method: "post", path: "/auth/login", payload: ["email", "password"], authorization: true },
    },
    bid: {
      list: { method: "get", path: "/bids", payload: ["ids"], option: { arrayFormat: "comma" } },
    },
  },
  getAuthorizeToken: () => localStorage.getItem("token"),
  baseUrl: "https://api.example.com",
  authorization: false, // 기본은 토큰 미첨부, 필요한 라우트만 true
  option: { arrayFormat: "repeat" }, // bid.list만 comma
});
```

## 에러 처리

비 2xx 응답 시 `Error`를 상속한 `OolioError`를 던집니다.

```javascript
import oolio, { OolioError } from "oolio";

try {
  await api.auth.login({ email: "test@example.com", password: "1234" });
} catch (error) {
  if (error instanceof OolioError) {
    error.status; // HTTP 상태 코드
    error.statusText; // 상태 텍스트
    error.data; // 서버 응답 데이터 (JSON이 아니면 원문 문자열, 빈 본문이면 "")
    error.url; // 요청 URL (쿼리 포함)
    error.method; // "POST"
    error.message; // "POST https://api.example.com/auth/login → 401 Unauthorized"
  }
}
```

- `message`에는 기본적으로 쿼리를 뺀 URL이 들어갑니다 (민감한 값이 로그·Sentry에 남지 않도록). 전체 URL이 필요하면 `option.errorMessageFullUrl: true`.
- 본문이 JSON이 아닌 오류 응답(게이트웨이 502 HTML, 빈 401 등)도 `status`를 잃지 않습니다.
- 성공 응답의 본문이 비어 있으면(204 등) `null`을, JSON이 아니면 원문 문자열을 반환합니다.
- 네트워크 오류 등 응답을 받지 못한 경우는 fetch가 던진 에러(`TypeError` 등)가 그대로 전달됩니다.

## 변경 이력

### 0.3.0

**신규 기능**

- `OolioError` 클래스 — 비 2xx 응답 시 `Error`를 상속한 에러를 던집니다. 기존 `status`/`statusText`/`data`에 `url`/`method`/`message`/`stack` 추가. `import { OolioError } from "oolio"`로 `instanceof` 판별 가능
- `onUnauthorized` — 401 시 토큰 갱신 후 원래 요청을 한 번 재시도. 동시 401은 한 번만 갱신
- `:param` 경로 파라미터 지원 — `{param}`과 동일하게 치환
- `option.arrayFormat` — GET 쿼리 배열 직렬화 형식 선택 (`json` 기본 / `repeat` / `bracket` / `comma`)
- `option.errorMessageFullUrl` — `OolioError.message`에 쿼리 포함 전체 URL 사용 (기본은 쿼리 제외)
- 옵션 우선순위 통일 (호출별 > 라우트 > 전역 > 기본값) — 라우트 `option`/`fetchOptions`/`onUnauthorized`, 전역 `authorization` 추가
- `payload` 선언 없이 인자를 넘기면 라우트마다 한 번 콘솔 경고

**버그 수정**

- `authorization: false` 라우트에 토큰이 붙던 문제 수정 (README에 적힌 대로 `false`/`"guest"` 모두 미첨부)
- 오류 응답 본문이 JSON이 아니면(502 HTML, 빈 401 등) `SyntaxError`가 나며 상태 코드를 잃던 문제 수정
- `retry` 재시도 시 옛 토큰이 그대로 나가던 문제 수정 — 재시도 직전 `getAuthorizeToken()`을 다시 읽음
- GET 쿼리가 비어도 URL 끝에 `?`가 붙던 문제 수정. `path`에 쿼리가 있으면 `&`로 이어 붙임
- 경로 파라미터 치환값에 `$&` 등 특수 패턴이 있으면 값이 망가지던 문제 수정

**동작 변경**

- 성공 응답 본문이 비면(204 등) 에러 대신 `null`, JSON이 아니면 원문 문자열을 반환
- GET 요청에도 fetch `init.method`에 `"GET"`을 명시 (`fetchOptions.method`보다 우선)
- `/:name` 형태의 경로 구간은 이제 경로 파라미터로 해석됨

### 0.2.9

**버그 수정**

- 와이어 HTTP 메서드를 대문자로 정규화 — fetch 스펙이 PATCH를 자동 대문자화 대상에서 제외해 소문자 `patch`가 그대로 전송되던 문제 수정. 일부 서버(예: Next 16 Node HTTP)가 소문자 메서드를 malformed로 간주해 빈 400으로 끊던 함정을 라이브러리 차원에서 방지
- GET 판정 및 본문 직렬화 분기를 대소문자 무관하게 변경 — `method`를 `"GET"`(대문자)로 선언해도 정상적으로 query string 직렬화

### 0.2.8

**신규 기능**

- `OolioConfig.fetchOptions` 추가 — 모든 요청의 `fetch` 호출에 병합할 init 옵션(`credentials`, `mode`, `cache`, `signal` 등) 주입. 쿠키 기반 인증(특히 cross-origin)을 위한 `credentials: "include"` 지원
- per-request `options.fetchOptions` 추가 — 호출별로 fetch 옵션 오버라이드 (클라이언트 레벨과 얕은 병합, 호출별 우선)
- 마지막 인자에 `fetchOptions` 키만 있어도 per-request options로 자동 인식 (기존 `headers` 키와 동일하게 동작)
- 병합된 fetch 옵션은 `RequestConfig.fetchOptions`로 노출되어 `request` 인터셉터에서 변형 가능. oolio가 관리하는 `method`/`body`/`headers`는 항상 최종 우선

### 0.2.7

**버그 수정**

- React Native 환경에서 파일 업로드가 동작하지 않던 문제 수정 — RN 파일 객체(`{ uri, type, name? }`)를 binary로 감지해 `multipart/form-data`로 자동 전환
- Node.js `Buffer`를 파일로 업로드할 수 없던 문제 수정 — `Buffer` 인스턴스를 binary로 감지해 `multipart/form-data`로 자동 전환

### 0.2.6

**문서 개선**

- `IO<TPayload, TResponse>` 타입 규칙 명시 — `TPayload`는 body/query 필드 전용이며 path params는 포함하지 않는다는 설명과 올바른/잘못된 예시 추가

### 0.2.5

**버그 수정**

- `ApiClient` 타입이 path params 있는 route에서 인자 2개를 받지 못하던 문제 수정 — `(pathParams, data?)` 시그니처를 오버로드로 추가해 TypeScript 에러 해소

### 0.2.4

**신규 기능**

- `OolioConfig.option.loggerPretty` 추가 — `logger: true`일 때 중첩 객체를 `JSON.stringify`로 전체 depth 출력

### 0.2.3

**Breaking changes**

- `IO` 옵션에서 `files` 필드 제거. 대신 `payload` 값 중 `File`/`Blob` 인스턴스가 있으면 자동으로 `multipart/form-data`로 전환됩니다. 기존에 `files`를 사용하던 경우 해당 키를 `payload`로 옮기기만 하면 됩니다.
- POST/PUT/DELETE 기본 직렬화 방식이 `multipart/form-data` → `application/json`으로 변경되었습니다.

**신규 기능**

- `OolioConfig.interceptors` 추가 — `request` / `response` / `responseError` / `retry` 4종 지원
- `OolioConfig.option.logger` 추가 — 요청·응답·에러 콘솔 출력, 동시 요청 구분용 임시 ID 포함
- per-request 옵션 — 마지막 인자로 `{ headers }` 오브젝트 전달 시 해당 요청에만 헤더 적용
- 사용자가 `headers["Content-Type"]`을 직접 지정한 경우 자동 감지보다 우선 적용
- `payload` 값에 `File`/`Blob`이 있으면 별도 설정 없이 `multipart/form-data`로 자동 전환

## 라이센스

MIT

---
_Source: https://npm.io/package/oolio · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
