Keep Kakao route contracts local and explicit

Constraint: PR #283 review requested TDD fixes for Kakao Local distance sorting, Mobility toll avoidance, lint coverage, and coord2region routing coverage.
Rejected: Relying on upstream Kakao validation for sort=distance | it spends quota and returns a proxy/upstream error instead of local bad_request.
Rejected: Document-only toll avoidance correction | the skill already promises the behavior and Kakao Mobility exposes an explicit avoid option.
Confidence: high
Scope-risk: narrow
Directive: Preserve server-side KAKAO_REST_API_KEY injection only; never accept or forward caller apiKey query values.
Tested: node --test packages/k-skill-proxy/test/server.test.js; npm test --workspace k-skill-proxy; npm run lint --workspace k-skill-proxy; node --test scripts/skill-docs.test.js; bash scripts/validate-skills.sh; manual Fastify inject smoke for sort=distance and avoid forwarding; npm run ci through lint/typecheck until local Python pyexpat failure.
Not-tested: Full npm run ci completion due local Python 3.14 pyexpat ImportError during pip install.
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-23 19:25:10 +09:00
commit 366d346f03
6 changed files with 110 additions and 11 deletions

View file

@ -27,7 +27,7 @@
| `GET /v1/kakao-map/search/category` | `https://dapi.kakao.com/v2/local/search/category.json` | `category_group_code`, `x`, `y`, `radius`, `sort`, `page`, `size` |
| `GET /v1/kakao-map/coord2address` | `https://dapi.kakao.com/v2/local/geo/coord2address.json` | `x`, `y`, `input_coord` |
| `GET /v1/kakao-map/coord2region` | `https://dapi.kakao.com/v2/local/geo/coord2regioncode.json` | `x`, `y`, `input_coord` |
| `GET /v1/kakao-mobility/directions` | `https://apis-navi.kakaomobility.com/v1/directions` | `origin=x,y`, `destination=x,y`, `waypoints`, `priority`(RECOMMEND\|TIME\|DISTANCE), `car_fuel`, `car_hipass`, `alternatives` |
| `GET /v1/kakao-mobility/directions` | `https://apis-navi.kakaomobility.com/v1/directions` | `origin=x,y`, `destination=x,y`, `waypoints`, `priority`(RECOMMEND\|TIME\|DISTANCE), `car_fuel`, `car_hipass`, `alternatives`, `avoid`(ferries\|toll\|motorway\|schoolzone\|uturn; `\|` 구분) |
## 기본 흐름
@ -65,7 +65,8 @@ curl -fsS --get "${BASE}/v1/kakao-map/coord2address" \
curl -fsS --get "${BASE}/v1/kakao-mobility/directions" \
--data-urlencode 'origin=126.9706,37.5559' \
--data-urlencode 'destination=127.0276,37.4979' \
--data-urlencode 'priority=RECOMMEND'
--data-urlencode 'priority=RECOMMEND' \
--data-urlencode 'avoid=toll'
```
응답 요약(예):
@ -74,7 +75,7 @@ curl -fsS --get "${BASE}/v1/kakao-mobility/directions" \
자동차 경로: (126.9706,37.5559) → (127.0276,37.4979)
- 거리: 12.3km / 예상 소요시간: 25분
- 통행료: 1,200원 / 예상 택시요금: 18,500원
- 옵션: RECOMMEND
- 옵션: RECOMMEND, avoid=toll
```
## fallback / 대체 흐름
@ -90,6 +91,7 @@ curl -fsS --get "${BASE}/v1/kakao-mobility/directions" \
- Kakao Mobility는 **자동차 전용**이다. 대중교통 길찾기는 [한국 대중교통 길찾기 가이드](korean-transit-route.md) 를 쓴다.
- 카테고리 검색은 좌표 중심(`x`, `y`)이 필수다.
- waypoints 는 최대 5개 (Kakao Mobility 정책).
- 통행료 회피는 `avoid=toll`을 사용한다. `priority=DISTANCE`는 최단거리 우선순위일 뿐 통행료 회피와 동의어가 아니다.
- Kakao Mobility 무료 일일 쿼터는 1,000건 수준이다. proxy cache + rate-limit이 보호 역할을 하지만, 대량 호출은 자제한다.
- 본 스킬은 데이터 조회 전용이다. 예약·결제·자동 운전은 하지 않는다.
- secret/token/.env 원문은 응답에 노출되지 않는다 (proxy가 키를 서버측에서만 주입).

View file

@ -27,7 +27,7 @@ Kakao Developers의 두 API를 `k-skill-proxy` 경유로 묶어 다음 두 종
- "역삼동 카페 카테고리로 보여줘" → category 검색 (FD6/CE7 등)
- "이 좌표가 어느 동/도로명 주소야?" → coord2address / coord2region
- "강남역에서 시청까지 자동차로 얼마나 걸려?" → Kakao Mobility directions
- "통행료 회피 경로로 알려줘" → priority=DISTANCE 또는 추가 옵션
- "통행료 회피 경로로 알려줘" → avoid=toll (필요 시 priority=DISTANCE 병행)
## When NOT to use
@ -58,7 +58,7 @@ Kakao Developers의 두 API를 `k-skill-proxy` 경유로 묶어 다음 두 종
| `GET /v1/kakao-map/search/category` | 카테고리 장소 검색 (좌표 중심 필수) | `category_group_code`(예: FD6 음식점, CE7 카페), `x`, `y`, `radius`(기본 500), `sort`, `page`, `size` |
| `GET /v1/kakao-map/coord2address` | 좌표 → 도로명/지번 주소 | `x`, `y`, optional `input_coord`(WGS84 기본) |
| `GET /v1/kakao-map/coord2region` | 좌표 → 행정구역(시/도/시군구/동) | `x`, `y`, optional `input_coord` |
| `GET /v1/kakao-mobility/directions` | 자동차 길찾기 | `origin=x,y`, `destination=x,y`, optional `waypoints`(최대 5, `\|` 구분), `priority`(RECOMMEND\|TIME\|DISTANCE), `car_fuel`(GASOLINE\|DIESEL\|LPG), `car_hipass`(true\|false), `alternatives`(true\|false) |
| `GET /v1/kakao-mobility/directions` | 자동차 길찾기 | `origin=x,y`, `destination=x,y`, optional `waypoints`(최대 5, `\|` 구분), `priority`(RECOMMEND\|TIME\|DISTANCE), `car_fuel`(GASOLINE\|DIESEL\|LPG), `car_hipass`(true\|false), `alternatives`(true\|false), `avoid`(ferries\|toll\|motorway\|schoolzone\|uturn; `\|` 구분) |
**Kakao 카테고리 그룹 코드** (자주 쓰는 것):
@ -130,7 +130,8 @@ curl -fsS --get "${BASE}/v1/kakao-map/coord2region" \
curl -fsS --get "${BASE}/v1/kakao-mobility/directions" \
--data-urlencode 'origin=126.9706,37.5559' \
--data-urlencode 'destination=127.0276,37.4979' \
--data-urlencode 'priority=RECOMMEND'
--data-urlencode 'priority=RECOMMEND' \
--data-urlencode 'avoid=toll'
```
응답에서 `routes[0].summary` 를 읽는다:
@ -139,6 +140,7 @@ curl -fsS --get "${BASE}/v1/kakao-mobility/directions" \
- `duration` (second) → 분 환산
- `fare.taxi`, `fare.toll` → 원
- `priority` (요청한 값 echo)
- `avoid` 요청 시 `toll` 등 회피 옵션 적용
### 6. 출력 포맷
@ -156,7 +158,7 @@ curl -fsS --get "${BASE}/v1/kakao-mobility/directions" \
자동차 경로: (126.9706,37.5559) → (127.0276,37.4979)
- 거리: 12.3km / 예상 소요시간: 25분
- 통행료: 1,200원 / 예상 택시요금: 18,500원
- 옵션: RECOMMEND
- 옵션: RECOMMEND, avoid=toll
- 조회 시각: 2026-05-23T14:00:00.000Z
```

View file

@ -29,7 +29,7 @@
- `GET /v1/kakao-map/search/category` — Kakao Local 카테고리 장소 검색(좌표 중심 필수, `KAKAO_REST_API_KEY`)
- `GET /v1/kakao-map/coord2address` — Kakao Local 좌표→도로명/지번 주소(`KAKAO_REST_API_KEY`)
- `GET /v1/kakao-map/coord2region` — Kakao Local 좌표→행정구역(`KAKAO_REST_API_KEY`)
- `GET /v1/kakao-mobility/directions` — Kakao Mobility 자동차 길찾기(`KAKAO_REST_API_KEY`)
- `GET /v1/kakao-mobility/directions` — Kakao Mobility 자동차 길찾기(`KAKAO_REST_API_KEY`; `avoid=toll|motorway` 등 회피 옵션 지원)
- `GET /v1/kosis/search` — KOSIS 통계표 검색(`KOSIS_API_KEY`)
- `GET /v1/kosis/meta` — KOSIS 통계표 메타데이터(`KOSIS_API_KEY`)
- `GET /v1/kosis/data` — KOSIS 통계 데이터 셀 조회(`KOSIS_API_KEY`)
@ -66,7 +66,7 @@
- `KEDU_INFO_KEY` — 프록시 서버 쪽 나이스(NEIS) 교육정보 개방 포털 Open API 인증키 (`school-search`, `school-meal`)
- `DATA4LIBRARY_AUTH_KEY` — 프록시 서버 쪽 도서관 정보나루 Open API 인증키 (`data4library/*`)
- `FOODSAFETYKOREA_API_KEY` — 프록시 서버 쪽 식품안전나라 회수정보 live key (`mfds/food-safety/search`; 없으면 sample feed fallback)
- `KAKAO_REST_API_KEY` — 프록시 서버 쪽 Kakao Local REST API 키 (`kakao-local/geocode`)
- `KAKAO_REST_API_KEY` — 프록시 서버 쪽 Kakao REST API 키 (`kakao-local/geocode`, `kakao-map/*`, `kakao-mobility/directions`)
- `KRX_API_KEY` — 프록시 서버 쪽 KRX Open API upstream key
- `KOSIS_API_KEY` 또는 `KSKILL_KOSIS_API_KEY` — 프록시 서버 쪽 KOSIS Open API upstream key (`kosis/search`, `kosis/meta`, `kosis/data`)
- `NAVER_SEARCH_CLIENT_ID`, `NAVER_SEARCH_CLIENT_SECRET` — 네이버 검색 Open API 키(`shop.json`, `news.json` 공통). 네이버 뉴스 route(`naver-news/search`)는 이 키가 **필수**이며 없으면 `503 upstream_not_configured` 를 돌려준다. 네이버 쇼핑 route(`naver-shopping/search`)는 **선택**이며 설정되면 공식 API 를 우선 사용하고, 없으면 공개 BFF JSON 파서로 fallback 한다. 공식 쇼핑 API 는 `review` 정렬을 지원하지 않아 `meta.sort_applied: "unsupported"`로 표시한다. no-key 쇼핑 fallback 은 `page`를 BFF에 전달해 해당 페이지를 고르고, `price_asc`/`price_dsc`/`review`는 선택 페이지 안에서 로컬 정렬하며, `date``meta.sort_applied: "unsupported"`로 표시

View file

@ -9,7 +9,7 @@
"node": ">=18"
},
"scripts": {
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/kstartup.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"lint": "node --check src/airkorea.js && node --check src/bluer.js && node --check src/hrfco.js && node --check src/krx-stock.js && node --check src/kstartup.js && node --check src/kakao-map.js && node --check src/lh-notice.js && node --check src/mfds.js && node --check src/molit.js && node --check src/naver-news.js && node --check src/naver-shopping.js && node --check src/nts-business.js && node --check src/parking-lots.js && node --check src/region-lookup.js && node --check src/server.js && node --check test/airkorea.test.js && node --check test/hrfco.test.js && node --check test/lh-notice.test.js && node --check test/molit.test.js && node --check test/naver-news.test.js && node --check test/naver-shopping.test.js && node --check test/region-lookup.test.js && node --check test/server.test.js",
"test": "node --test"
},
"dependencies": {

View file

@ -26,6 +26,7 @@ const KAKAO_CATEGORY_GROUP_CODES = new Set([
const KAKAO_MOBILITY_PRIORITY = new Set(["RECOMMEND", "TIME", "DISTANCE"]);
const KAKAO_MOBILITY_CAR_FUEL = new Set(["GASOLINE", "DIESEL", "LPG"]);
const KAKAO_MOBILITY_ROAD_DETAILS = new Set(["true", "false"]);
const KAKAO_MOBILITY_AVOID = new Set(["ferries", "toll", "motorway", "schoolzone", "uturn"]);
function trimOrNull(value) {
if (value === undefined || value === null) {
@ -128,6 +129,9 @@ function normalizeKakaoKeywordSearchQuery(query) {
if (sort !== "distance" && sort !== "accuracy") {
throw new Error("Provide sort as 'distance' or 'accuracy'.");
}
if (sort === "distance" && (!result.x || !result.y)) {
throw new Error("Provide both x (lng) and y (lat) when using sort=distance.");
}
result.sort = sort;
}
@ -286,6 +290,16 @@ function normalizeKakaoMobilityDirectionsQuery(query) {
alternatives = lower === "true";
}
const avoidRaw = trimOrNull(query.avoid);
let avoid = null;
if (avoidRaw) {
const values = avoidRaw.split("|").map((entry) => entry.trim().toLowerCase()).filter(Boolean);
if (values.length === 0 || values.some((entry) => !KAKAO_MOBILITY_AVOID.has(entry))) {
throw new Error(`Provide avoid as pipe-separated values from ${[...KAKAO_MOBILITY_AVOID].join(", ")}.`);
}
avoid = values.join("|");
}
return {
origin: originRaw,
destination: destinationRaw,
@ -293,7 +307,8 @@ function normalizeKakaoMobilityDirectionsQuery(query) {
priority,
car_fuel: carFuel,
car_hipass: carHipass,
alternatives
alternatives,
avoid
};
}
@ -384,6 +399,7 @@ async function fetchKakaoMobilityDirections({
car_fuel,
car_hipass,
alternatives,
avoid,
apiKey,
fetchImpl = global.fetch
}) {
@ -410,6 +426,9 @@ async function fetchKakaoMobilityDirections({
if (alternatives !== null && alternatives !== undefined) {
url.searchParams.set("alternatives", String(alternatives));
}
if (avoid) {
url.searchParams.set("avoid", avoid);
}
let response;
try {
@ -475,6 +494,7 @@ module.exports = {
KAKAO_CATEGORY_GROUP_CODES,
KAKAO_MOBILITY_PRIORITY,
KAKAO_MOBILITY_CAR_FUEL,
KAKAO_MOBILITY_AVOID,
fetchKakaoLocalEndpoint,
fetchKakaoMobilityDirections,
normalizeKakaoKeywordSearchQuery,

View file

@ -784,6 +784,13 @@ test("Kakao Map keyword search validates coordinate pairing and radius bounds",
url: "/v1/kakao-map/search/keyword?q=hi&x=127.0&y=37.5&radius=99999"
});
assert.equal(badRadius.statusCode, 400);
const distanceSortWithoutCoords = await app.inject({
method: "GET",
url: "/v1/kakao-map/search/keyword?q=hi&sort=distance"
});
assert.equal(distanceSortWithoutCoords.statusCode, 400);
assert.match(distanceSortWithoutCoords.json().message, /sort=distance/i);
});
test("Kakao Map category search rejects unsupported category group codes", async (t) => {
@ -839,6 +846,39 @@ test("Kakao Map category search routes to /search/category.json with FD6 and coo
assert.equal(parsed.searchParams.get("category_group_code"), "FD6");
});
test("Kakao Map coord2region routes to /geo/coord2regioncode.json with input_coord", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url) => {
calls.push(String(url));
return new Response(
JSON.stringify({
meta: { total_count: 1 },
documents: [{ region_type: "B", address_name: "서울특별시 강남구 역삼동" }]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({
env: { KAKAO_REST_API_KEY: "k" }
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/kakao-map/coord2region?x=127.0276&y=37.4979&input_coord=WGS84"
});
assert.equal(response.statusCode, 200);
assert.match(response.json().documents[0].address_name, /강남구/);
const parsed = new URL(calls[0]);
assert.equal(parsed.origin + parsed.pathname, "https://dapi.kakao.com/v2/local/geo/coord2regioncode.json");
assert.equal(parsed.searchParams.get("input_coord"), "WGS84");
});
test("Kakao Map coord2address routes to /geo/coord2address.json with x/y", async (t) => {
const originalFetch = global.fetch;
const calls = [];
@ -947,6 +987,35 @@ test("Kakao Mobility directions endpoint injects KakaoAK, forwards priority/opti
assert.equal(calls[0].headers.authorization, "KakaoAK mob-key");
});
test("Kakao Mobility directions forwards whitelisted avoid options", async (t) => {
const originalFetch = global.fetch;
const calls = [];
global.fetch = async (url, options = {}) => {
calls.push({ url: String(url), headers: options.headers });
return new Response(
JSON.stringify({
trans_id: "avoid",
routes: [{ result_code: 0, result_msg: "성공", summary: { distance: 1000, duration: 300 } }]
}),
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
);
};
const app = buildServer({ env: { KAKAO_REST_API_KEY: "mob-key" } });
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/kakao-mobility/directions?origin=126.9706,37.5559&destination=127.0276,37.4979&avoid=toll%7Cmotorway"
});
assert.equal(response.statusCode, 200);
const parsed = new URL(calls[0].url);
assert.equal(parsed.searchParams.get("avoid"), "toll|motorway");
});
test("Kakao Mobility directions endpoint validates coordinate, priority, and waypoint count", async (t) => {
const app = buildServer({ env: { KAKAO_REST_API_KEY: "k" } });
t.after(async () => {
@ -965,6 +1034,12 @@ test("Kakao Mobility directions endpoint validates coordinate, priority, and way
});
assert.equal(badPriority.statusCode, 400);
const badAvoid = await app.inject({
method: "GET",
url: "/v1/kakao-mobility/directions?origin=127.0,37.5&destination=127.1,37.6&avoid=unpaved"
});
assert.equal(badAvoid.statusCode, 400);
const tooManyWaypoints = await app.inject({
method: "GET",
url: "/v1/kakao-mobility/directions?origin=127.0,37.5&destination=127.1,37.6&waypoints="