mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
feat(naver-map-route): NCP Maps Directions/Geocode/Reverse-Geocode 프록시 라우트 + MVP 길찾기 스킬 (#268)
- packages/k-skill-proxy: NAVER_MAP_CLIENT_ID/SECRET 서버측 보관, /v1/naver-map/{directions,geocode,reverse-geocode} 라우트 3종 추가
- naver-map-route: instruction-level MVP 스킬 (mock 기본, ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true + ROUTE_PLANNER_PROVIDER=naver 에서만 live)
- /route, /이동루트 수동 입력 처리, graceful fallback 정책 문서화
- proxy 테스트 8건 신규 (missing-key 503, 캐시, 좌표 검증, semantic failure non-cache, auth error sanitize, geocode 헤더 주입, reverse-geocode orders 검증, health 플래그)
- README 표/포함된 기능, packages/k-skill-proxy/README, docs/features/naver-map-route, docs/sources, changeset 동시 갱신
Closes #268
This commit is contained in:
parent
876077c7c9
commit
ff2aa91f83
9 changed files with 1088 additions and 0 deletions
5
.changeset/issue-268-naver-map-route.md
Normal file
5
.changeset/issue-268-naver-map-route.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"k-skill-proxy": minor
|
||||
---
|
||||
|
||||
Add NAVER Cloud Platform Maps directions, geocoding, and reverse-geocoding proxy routes used by the new naver-map-route skill (issue #268). Routes inject server-side NAVER_MAP_CLIENT_ID/SECRET and return 503 when the upstream key is missing.
|
||||
|
|
@ -32,6 +32,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 서울 실시간 혼잡도 조회 | `seoul-density` | 서울 주요 121개 핫스팟의 실시간 혼잡도 단계와 추정 인구 조회 | 불필요 | [서울 실시간 혼잡도 가이드](docs/features/seoul-density.md) |
|
||||
| 서울 따릉이 실시간 대여소 조회 | `seoul-bike` | 현재 좌표 주변 또는 대여소 이름 기준 따릉이 대여 가능 자전거와 빈 거치대 조회 | 불필요 | [서울 따릉이 실시간 대여소 가이드](docs/features/seoul-bike.md) |
|
||||
| 한국 대중교통 길찾기 | `korean-transit-route` | ODsay LIVE API + Kakao geocoding 기반 출발지→도착지 지하철+버스+도보 경로 및 환승 정보 조회 | 필요 | [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md) |
|
||||
| 네이버맵 자동차 길찾기 | `naver-map-route` | `/route`·`/이동루트` 수동 입력 기반 NCP Maps Directions 5 자동차 경로, 지오코딩, 역지오코딩 조회 (mock 기본, live opt-in) | 불필요 | [네이버맵 길찾기 가이드](docs/features/naver-map-route.md) |
|
||||
| 지하철 분실물 조회 | `subway-lost-property` | 지하철 역/물품명 기준 공식 LOST112 분실물 검색 조건과 유실물센터 진입점 안내 | 불필요 | [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md) |
|
||||
| 긱뉴스 조회 | `geeknews-search` | GeekNews 공개 RSS/Atom 피드 기반 최신 글 목록, 검색, 상세 확인 | 불필요 | [긱뉴스 조회 가이드](docs/features/geeknews-search.md) |
|
||||
| 한국 날씨 조회 | `korea-weather` | 기상청 단기예보 기반 한국 날씨 조회 | 불필요 | [한국 날씨 조회 가이드](docs/features/korea-weather.md) |
|
||||
|
|
@ -150,6 +151,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
|
||||
- [서울 실시간 혼잡도 조회](docs/features/seoul-density.md)
|
||||
- [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md)
|
||||
- [네이버맵 길찾기 가이드](docs/features/naver-map-route.md)
|
||||
- [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md)
|
||||
- [긱뉴스 조회 가이드](docs/features/geeknews-search.md)
|
||||
- [한국 날씨 조회 가이드](docs/features/korea-weather.md)
|
||||
|
|
|
|||
111
docs/features/naver-map-route.md
Normal file
111
docs/features/naver-map-route.md
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
# 네이버맵 길찾기 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- 출발지·목적지를 좌표(`lng,lat`) 또는 주소로 받아 NAVER Cloud Platform Maps Directions 5 결과를 `k-skill-proxy` 경유로 조회
|
||||
- 자동차 경로의 거리·소요 시간·통행료·연료비 요약
|
||||
- 주소 → 좌표(Naver Geocoding), 좌표 → 주소(Reverse Geocoding) 보조 조회
|
||||
- `/route`, `/이동루트` 명령으로 호출되는 instruction-level 워크플로
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- [공통 설정 가이드](../setup.md) 확인
|
||||
- 사용자는 별도 NAVER Map key 발급 필요 없음
|
||||
- 운영자(proxy 서버)는 NAVER_MAP_CLIENT_ID·NAVER_MAP_CLIENT_SECRET 보유
|
||||
|
||||
## 기본 경로
|
||||
|
||||
기본 hosted path: `https://k-skill-proxy.nomadamas.org/v1/naver-map/*`
|
||||
|
||||
`KSKILL_PROXY_BASE_URL` 환경변수로 override 가능.
|
||||
|
||||
## Provider 결정
|
||||
|
||||
| 환경변수 | 효과 |
|
||||
|---|---|
|
||||
| `ROUTE_PLANNER_PROVIDER=naver` | naver provider 활성화 후보 |
|
||||
| `ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true` | live proxy 호출 명시 허용 |
|
||||
| 둘 중 하나라도 미설정 | mock 결과 반환 |
|
||||
|
||||
이 게이트는 **기본을 mock으로 잠그는 안전장치**다. 명시 활성화 없이 운영자 proxy를 호출하지 않는다.
|
||||
|
||||
## Proxy routes
|
||||
|
||||
| endpoint | upstream | 주요 입력 |
|
||||
|---|---|---|
|
||||
| `GET /v1/naver-map/directions` | NCP Maps Directions 5 | `start=lng,lat`, `goal=lng,lat`, `waypoints` (`\|` 구분 최대 5), `option`(trafast 기본), `lang=ko` |
|
||||
| `GET /v1/naver-map/geocode` | NCP Maps Geocoding | `q`, `coordinate`, `filter`, `language`, `page`, `count` |
|
||||
| `GET /v1/naver-map/reverse-geocode` | NCP Maps Reverse Geocoding | `coords=lng,lat`, `orders=roadaddr,addr,legalcode,admcode`, `output=json` |
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. client/skill 은 `/route` 또는 `/이동루트` 명령으로 출발지·목적지 수동 입력을 받는다.
|
||||
2. provider 결정 게이트를 확인한다 (`ROUTE_PLANNER_*` 환경변수).
|
||||
3. mock 모드: 형식만 갖춘 응답을 즉시 반환하고 `provider: "mock"` 표기.
|
||||
4. live 모드:
|
||||
- 주소만 있으면 `/v1/naver-map/geocode` 로 좌표를 얻는다.
|
||||
- `/v1/naver-map/directions` 로 경로를 조회한다.
|
||||
- 응답의 `route.trafast[0].summary` 를 거리/시간/통행료/연료비로 매핑한다.
|
||||
5. live 실패(503/502/네트워크) 시 mock fallback 으로 떨어지고, 사용자에게 fallback 임을 명시한다.
|
||||
|
||||
## 예시
|
||||
|
||||
mock 모드:
|
||||
|
||||
```bash
|
||||
ROUTE_PLANNER_ENABLE_LIVE_PROVIDER= # 또는 미설정
|
||||
# 결과
|
||||
# {
|
||||
# "provider": "mock",
|
||||
# "start": { "label": "강남역" },
|
||||
# "goal": { "label": "시청역" },
|
||||
# "summary": { "distance_km": null, "duration_minutes": null, "toll_won": null, "fuel_won": null },
|
||||
# "note": "live provider is disabled."
|
||||
# }
|
||||
```
|
||||
|
||||
live 모드 (proxy 직접 호출 예시):
|
||||
|
||||
```bash
|
||||
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
|
||||
curl -fsS --get "${BASE}/v1/naver-map/directions" \
|
||||
--data-urlencode 'start=126.9706,37.5559' \
|
||||
--data-urlencode 'goal=127.0276,37.4979' \
|
||||
--data-urlencode 'option=trafast'
|
||||
```
|
||||
|
||||
응답 예상 요약:
|
||||
|
||||
```text
|
||||
경로 요약 (naver): 시청역(126.9706,37.5559) → 강남역(127.0276,37.4979)
|
||||
- 거리: 12.3km
|
||||
- 예상 소요시간: 25분
|
||||
- 통행료: 1,200원
|
||||
- 연료비: 1,500원
|
||||
- 옵션: trafast
|
||||
- 조회 시각: 2026-05-23T14:00:00.000Z
|
||||
```
|
||||
|
||||
## fallback / 대체 흐름
|
||||
|
||||
- 키 누락(`503 upstream_not_configured`) → mock fallback + 사용자에게 안내
|
||||
- 인증 실패(401/403) → proxy 가 `503` 으로 변환 → mock fallback
|
||||
- 경로 미발견(`code != 0`) → `502 upstream_semantic_error` → 메시지와 함께 안내
|
||||
- 네트워크 실패 → `502 upstream_error` → mock fallback
|
||||
- 좌표 형식 오류 → `400 bad_request`
|
||||
|
||||
## 주의할 점
|
||||
|
||||
- 본 스킬은 **자동차 경로**에 한정한다. 도보·자전거·대중교통은 다른 스킬을 사용한다.
|
||||
- 현재 위치 자동 인식과 캘린더 읽기는 의도적으로 범위에서 제외된다 (이슈 #268 OUT).
|
||||
- waypoints 는 최대 5개 (NCP Maps 정책).
|
||||
- option 값은 `trafast`(빠른 경로), `tracomfort`(편안), `traoptimal`(최적), `traavoidtoll`(통행료 회피), `traavoidcaronly`(자동차전용 회피) 중 하나.
|
||||
- secret/token/.env 원문은 응답에 노출되지 않는다 (proxy가 키를 서버 측에서만 주입).
|
||||
|
||||
## 참고 표면
|
||||
|
||||
- NAVER Cloud Platform Maps Console: `https://www.ncloud.com/product/applicationService/maps`
|
||||
- Maps Directions 5 endpoint: `https://maps.apigw.ntruss.com/map-direction/v1/driving`
|
||||
- Maps Geocoding endpoint: `https://maps.apigw.ntruss.com/map-geocode/v2/geocode`
|
||||
- Maps Reverse Geocoding endpoint: `https://maps.apigw.ntruss.com/map-reversegeocode/v2/gc`
|
||||
- proxy 운영 안내: [k-skill 프록시 서버 가이드](k-skill-proxy.md)
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
- 국가데이터처(구 통계청) KOSIS Open API 공식 진입: https://kosis.kr/openapi/ (회원가입·활용신청·개발가이드는 사이트 내부 메뉴 — 직접 deep-link는 SSO/SPA 라우팅으로 빈 화면이 보일 수 있다)
|
||||
- KOSIS Open API endpoint host: https://kosis.kr/openapi/ — 일반 helper 호출은 `k-skill-proxy`의 `/v1/kosis/search`, `/v1/kosis/meta`, `/v1/kosis/data`가 이 host의 `/statisticsSearch.do`, `/statisticsData.do`, `/Param/statisticsParameterData.do` 로 중계한다. `bigdata`/`--direct`는 `/statisticsBigData.do` 등을 직접 호출한다 (HTTPS 전용, 2026-03-05 시행)
|
||||
- Kakao Local API endpoint host: https://dapi.kakao.com/v2/local/ — `k-skill-proxy`의 `/v1/kakao-local/geocode`가 `/search/address.json` → empty result 시 `/search/keyword.json` 순서로 중계한다.
|
||||
- NAVER Cloud Platform Maps Console: https://www.ncloud.com/product/applicationService/maps — `k-skill-proxy`의 `/v1/naver-map/directions`, `/v1/naver-map/geocode`, `/v1/naver-map/reverse-geocode`가 각각 `https://maps.apigw.ntruss.com/map-direction/v1/driving`, `/map-geocode/v2/geocode`, `/map-reversegeocode/v2/gc` 로 중계한다. 운영자 `NAVER_MAP_CLIENT_ID`/`NAVER_MAP_CLIENT_SECRET` 가 필요하다.
|
||||
- 숲나들e 공식 사이트: https://foresttrip.go.kr/index.jsp
|
||||
- 숲나들e 로그인: https://www.foresttrip.go.kr/com/login.do
|
||||
- 숲나들e 월별예약조회 화면: https://www.foresttrip.go.kr/rep/or/sssn/monthRsrvtSmplStatus.do
|
||||
|
|
|
|||
179
naver-map-route/SKILL.md
Normal file
179
naver-map-route/SKILL.md
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
---
|
||||
name: naver-map-route
|
||||
description: 네이버 지도(NAVER Cloud Platform Maps) 기반 출발지→목적지 자동차 길찾기·지오코딩·역지오코딩을 k-skill-proxy 경유로 조회한다. 수동 입력 MVP, mock 기본, live opt-in.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: transit
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# Naver Map Route (네이버 지도 길찾기 MVP)
|
||||
|
||||
## What this skill does
|
||||
|
||||
사용자가 `/route` 또는 `/이동루트` 명령으로 출발지·목적지를 직접 입력하면, **NAVER Cloud Platform Maps Directions 5** 결과를 `k-skill-proxy` 경유로 조회하여 거리·소요 시간·통행료·연료비를 요약한다.
|
||||
|
||||
- 운영자가 NCP Maps key를 proxy 서버 쪽에만 보관하고, 사용자는 별도 key가 필요하지 않다.
|
||||
- 기본 모드는 **mock**이다. 명시 활성화(`ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true` + `ROUTE_PLANNER_PROVIDER=naver`)될 때만 live proxy 호출을 수행한다.
|
||||
- 키 누락·인증 실패 시 graceful fallback으로 mock 결과를 안내한다.
|
||||
|
||||
이슈 #268 의 MVP 수용 기준:
|
||||
|
||||
- [x] `/route` 수동 입력 정상 응답
|
||||
- [x] `/이동루트` 수동 입력 정상 응답
|
||||
- [x] 기본 mock 모드에서 안정 동작
|
||||
- [x] live 명시 활성화 + 키 존재 시 naver provider 선택
|
||||
- [x] 키 누락/실패 시 fallback 응답
|
||||
- [x] secret/token/.env 원문 미노출
|
||||
|
||||
## When to use
|
||||
|
||||
- "/route 강남역에서 시청역" 같은 한 줄 수동 입력
|
||||
- "/이동루트 출발: <주소> / 도착: <주소>"
|
||||
- "강남역에서 시청까지 차로 얼마나 걸려?" (수동 좌표/주소 입력으로 변환 후 길찾기)
|
||||
- 자동차 기준 경로 요약, 거리·소요 시간·통행료·연료비 확인
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- 도보·자전거·대중교통 경로 (대중교통은 기존 `korean-transit-route` 스킬, 도보·자전거는 별도 스킬)
|
||||
- 실시간 교통 변동을 1분 단위로 추적하는 작업 (proxy cache가 있음)
|
||||
- 현재 위치 자동 인식 / 캘린더 연동 (MVP 범위 밖)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3 표준 라이브러리만 사용한다 (`urllib`, `argparse`, `json`).
|
||||
- optional: `KSKILL_PROXY_BASE_URL` (self-host·별도 프록시를 쓸 때만 설정. 비우면 기본 hosted `https://k-skill-proxy.nomadamas.org`).
|
||||
- optional: `ROUTE_PLANNER_PROVIDER=naver` (값이 `naver`일 때만 live provider 후보).
|
||||
- optional: `ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true` (live 호출을 명시 허용).
|
||||
|
||||
## Required environment variables
|
||||
|
||||
사용자 머신에는 **필요 없다.** 운영자가 proxy 서버 쪽에 다음을 둔다:
|
||||
|
||||
- `NAVER_MAP_CLIENT_ID` — NCP Maps subaccount client id
|
||||
- `NAVER_MAP_CLIENT_SECRET` — NCP Maps subaccount client secret
|
||||
|
||||
proxy 서버가 이 키 없이 가동되면 `/v1/naver-map/*` 라우트는 `503 upstream_not_configured` 를 돌려준다. 클라이언트는 이를 mock fallback 신호로 사용한다.
|
||||
|
||||
## Decision flow
|
||||
|
||||
```
|
||||
provider 결정
|
||||
├── ROUTE_PLANNER_ENABLE_LIVE_PROVIDER != "true"
|
||||
│ → mock 결과 반환
|
||||
├── ROUTE_PLANNER_PROVIDER != "naver"
|
||||
│ → mock 결과 반환
|
||||
└── live 시도
|
||||
├── proxy /v1/naver-map/directions 호출
|
||||
├── 503 / 502 / 네트워크 실패
|
||||
│ → mock fallback + warning 메모
|
||||
└── 정상 응답
|
||||
→ 요약 + provider="naver"
|
||||
```
|
||||
|
||||
## Proxy routes
|
||||
|
||||
| endpoint | upstream | 주요 입력 |
|
||||
|---|---|---|
|
||||
| `GET /v1/naver-map/directions` | NCP Maps Directions 5 (`/map-direction/v1/driving`) | `start=lng,lat`, `goal=lng,lat`, `waypoints` (최대 5), `option=trafast\|tracomfort\|traoptimal\|traavoidtoll\|traavoidcaronly`, `lang=ko` |
|
||||
| `GET /v1/naver-map/geocode` | NCP Maps Geocoding (`/map-geocode/v2/geocode`) | `q`, `coordinate`, `filter`, `language`, `page`, `count` |
|
||||
| `GET /v1/naver-map/reverse-geocode` | NCP Maps Reverse Geocoding (`/map-reversegeocode/v2/gc`) | `coords=lng,lat`, `orders=roadaddr,addr,legalcode,admcode`, `output=json` |
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. 사용자 입력 정리
|
||||
|
||||
- `/route <start>, <goal>` 또는 `/이동루트 출발: <start> 도착: <goal>` 패턴을 받는다.
|
||||
- 좌표(`126.9706,37.5559`) 또는 주소(`강남역 1번 출구`) 둘 다 허용. 주소는 geocode 단계로 좌표를 얻는다.
|
||||
|
||||
### 2. mock 모드 (기본)
|
||||
|
||||
`ROUTE_PLANNER_ENABLE_LIVE_PROVIDER` 가 비어 있거나 `true`가 아니면 즉시 mock 결과를 만든다:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "mock",
|
||||
"start": { "label": "강남역", "lng": null, "lat": null },
|
||||
"goal": { "label": "시청역", "lng": null, "lat": null },
|
||||
"summary": {
|
||||
"distance_km": null,
|
||||
"duration_minutes": null,
|
||||
"toll_won": null,
|
||||
"fuel_won": null
|
||||
},
|
||||
"note": "live provider is disabled. Set ROUTE_PLANNER_PROVIDER=naver and ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true to call the proxy."
|
||||
}
|
||||
```
|
||||
|
||||
### 3. live 모드
|
||||
|
||||
`ROUTE_PLANNER_PROVIDER=naver` + `ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true`:
|
||||
|
||||
```bash
|
||||
BASE="${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}"
|
||||
curl -fsS --get "${BASE}/v1/naver-map/directions" \
|
||||
--data-urlencode 'start=126.9706,37.5559' \
|
||||
--data-urlencode 'goal=127.0276,37.4979' \
|
||||
--data-urlencode 'option=trafast'
|
||||
```
|
||||
|
||||
응답에서 `route.trafast[0].summary` 를 읽어 다음으로 매핑한다:
|
||||
|
||||
- `distance` (meter) → `distance_km = distance / 1000`
|
||||
- `duration` (millisecond) → `duration_minutes = duration / 60000`
|
||||
- `tollFare` → `toll_won`
|
||||
- `fuelPrice` → `fuel_won`
|
||||
|
||||
### 4. 주소 → 좌표 변환 (필요할 때만)
|
||||
|
||||
사용자가 좌표를 모르고 주소만 줬을 때:
|
||||
|
||||
```bash
|
||||
curl -fsS --get "${BASE}/v1/naver-map/geocode" \
|
||||
--data-urlencode 'q=강남역 1번 출구' \
|
||||
--data-urlencode 'count=1'
|
||||
```
|
||||
|
||||
응답의 `addresses[0].x` (lng), `addresses[0].y` (lat) 를 사용한다.
|
||||
|
||||
### 5. 출력 포맷
|
||||
|
||||
```
|
||||
[mock 모드]
|
||||
경로 요약 (mock): 강남역 → 시청역
|
||||
- 거리/소요시간/통행료 정보 없음
|
||||
- live 활성화 방법: ROUTE_PLANNER_PROVIDER=naver, ROUTE_PLANNER_ENABLE_LIVE_PROVIDER=true
|
||||
|
||||
[live 모드]
|
||||
경로 요약 (naver): 강남역(126.9706,37.5559) → 시청역(127.0276,37.4979)
|
||||
- 거리: 12.3km
|
||||
- 예상 소요시간: 25분
|
||||
- 통행료: 1,200원
|
||||
- 연료비: 1,500원
|
||||
- 옵션: trafast
|
||||
- 조회 시각: 2026-05-23T14:00:00.000Z
|
||||
```
|
||||
|
||||
## Failure modes
|
||||
|
||||
- proxy upstream key 미설정 (`NAVER_MAP_CLIENT_ID/SECRET` 없음) → `503 upstream_not_configured` → mock fallback
|
||||
- NCP Maps 인증 실패 (401/403) → proxy가 `503` 으로 변환 → mock fallback
|
||||
- 경로 미발견 (`code != 0`) → `502 upstream_semantic_error` → 메시지와 함께 안내
|
||||
- 좌표 형식 오류 → `400 bad_request`
|
||||
- 네트워크 실패 → `502 upstream_error` → mock fallback
|
||||
|
||||
## Done when
|
||||
|
||||
- 사용자가 `/route` 또는 `/이동루트` 로 출발지·목적지를 줬을 때, mock 또는 live 결과로 한 가지가 명확히 응답된다.
|
||||
- live 응답에는 거리/시간/통행료/연료비/조회 시각이 정리되어 있다.
|
||||
- secret/token/.env 원문은 응답에 절대 노출되지 않는다.
|
||||
- live 실패 시 mock fallback 이 작동하고, fallback 임을 사용자에게 명시한다.
|
||||
|
||||
## Notes
|
||||
|
||||
- 본 MVP는 **자동차 경로**에 한정한다. 도보·자전거·대중교통은 별도 스킬을 사용한다.
|
||||
- waypoints 는 최대 5개 (NCP Maps 정책).
|
||||
- option=`trafast`(빠른 경로) 가 기본. 정확한 정의는 NCP Maps Directions 5 공식 문서를 참고.
|
||||
- proxy 운영/환경변수 설정은 `docs/features/k-skill-proxy.md` 를 참고한다.
|
||||
- 현재 위치 자동 인식·캘린더 읽기는 의도적으로 범위에서 제외된다(이슈 #268 OUT).
|
||||
|
|
@ -25,6 +25,9 @@
|
|||
- `GET /v1/korean-stock/base-info`
|
||||
- `GET /v1/korean-stock/trade-info`
|
||||
- `GET /v1/kakao-local/geocode` — Kakao Local 주소/장소명 지오코딩(`KAKAO_REST_API_KEY`; caller `apiKey` 무시)
|
||||
- `GET /v1/naver-map/directions` — NCP Maps Directions 5 자동차 길찾기(`NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET`)
|
||||
- `GET /v1/naver-map/geocode` — NCP Maps 주소→좌표 지오코딩(`NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET`)
|
||||
- `GET /v1/naver-map/reverse-geocode` — NCP Maps 좌표→주소 역지오코딩(`NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET`)
|
||||
- `GET /v1/kosis/search` — KOSIS 통계표 검색(`KOSIS_API_KEY`)
|
||||
- `GET /v1/kosis/meta` — KOSIS 통계표 메타데이터(`KOSIS_API_KEY`)
|
||||
- `GET /v1/kosis/data` — KOSIS 통계 데이터 셀 조회(`KOSIS_API_KEY`)
|
||||
|
|
@ -65,6 +68,7 @@
|
|||
- `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"`로 표시
|
||||
- `NAVER_MAP_CLIENT_ID`, `NAVER_MAP_CLIENT_SECRET` — NAVER Cloud Platform Maps subaccount 키. `naver-map/*` 라우트의 **운영 가능 여부**를 결정한다. 키가 없으면 라우트는 `503 upstream_not_configured` 를 돌려준다.
|
||||
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
|
||||
- `KSKILL_PROXY_PORT` — 기본 `4020`
|
||||
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
|
||||
|
|
|
|||
385
packages/k-skill-proxy/src/naver-map.js
Normal file
385
packages/k-skill-proxy/src/naver-map.js
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
const NAVER_MAP_DIRECTIONS_URL = "https://maps.apigw.ntruss.com/map-direction/v1/driving";
|
||||
const NAVER_MAP_GEOCODE_URL = "https://maps.apigw.ntruss.com/map-geocode/v2/geocode";
|
||||
const NAVER_MAP_REVERSE_GEOCODE_URL = "https://maps.apigw.ntruss.com/map-reversegeocode/v2/gc";
|
||||
|
||||
const ALLOWED_DRIVING_OPTIONS = new Set([
|
||||
"trafast",
|
||||
"tracomfort",
|
||||
"traoptimal",
|
||||
"traavoidtoll",
|
||||
"traavoidcaronly"
|
||||
]);
|
||||
|
||||
function trimOrNull(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = String(value).trim();
|
||||
if (!trimmed || trimmed === "replace-me") {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function parseFloatValue(value) {
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
||||
}
|
||||
|
||||
function parseCoordPair(value, label) {
|
||||
const trimmed = trimOrNull(value);
|
||||
if (!trimmed) {
|
||||
throw new Error(`Provide ${label} as "lng,lat".`);
|
||||
}
|
||||
const parts = trimmed.split(",").map((part) => part.trim());
|
||||
if (parts.length !== 2) {
|
||||
throw new Error(`Provide ${label} as "lng,lat".`);
|
||||
}
|
||||
const lng = parseFloatValue(parts[0]);
|
||||
const lat = parseFloatValue(parts[1]);
|
||||
if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
|
||||
throw new Error(`Provide ${label} as numeric "lng,lat".`);
|
||||
}
|
||||
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
||||
throw new Error(`Provide valid ${label} coordinates.`);
|
||||
}
|
||||
return `${lng},${lat}`;
|
||||
}
|
||||
|
||||
function normalizeNaverMapDirectionsQuery(query) {
|
||||
const start = parseCoordPair(query.start ?? query.from ?? query.origin, "start");
|
||||
const goal = parseCoordPair(query.goal ?? query.to ?? query.destination, "goal");
|
||||
|
||||
const rawWaypoints = query.waypoints ?? query.waypoint;
|
||||
let waypoints = null;
|
||||
if (rawWaypoints !== undefined && rawWaypoints !== null && rawWaypoints !== "") {
|
||||
const entries = Array.isArray(rawWaypoints) ? rawWaypoints : String(rawWaypoints).split("|");
|
||||
if (entries.length > 5) {
|
||||
throw new Error("Provide at most 5 waypoints.");
|
||||
}
|
||||
waypoints = entries.map((entry, index) => parseCoordPair(entry, `waypoint[${index}]`)).join("|");
|
||||
}
|
||||
|
||||
const rawOption = trimOrNull(query.option);
|
||||
let option = "trafast";
|
||||
if (rawOption) {
|
||||
const candidate = rawOption.toLowerCase();
|
||||
if (!ALLOWED_DRIVING_OPTIONS.has(candidate)) {
|
||||
throw new Error(
|
||||
`Provide option as one of ${[...ALLOWED_DRIVING_OPTIONS].join(", ")}.`
|
||||
);
|
||||
}
|
||||
option = candidate;
|
||||
}
|
||||
|
||||
const lang = trimOrNull(query.lang) || "ko";
|
||||
|
||||
return { start, goal, waypoints, option, lang };
|
||||
}
|
||||
|
||||
function normalizeNaverMapGeocodeQuery(query) {
|
||||
const q = trimOrNull(query.q ?? query.query);
|
||||
if (!q) {
|
||||
throw new Error("Provide query.");
|
||||
}
|
||||
|
||||
const coordinate = trimOrNull(query.coordinate);
|
||||
if (coordinate) {
|
||||
// validate format only; pass through to upstream
|
||||
parseCoordPair(coordinate, "coordinate");
|
||||
}
|
||||
|
||||
const filter = trimOrNull(query.filter);
|
||||
const language = trimOrNull(query.language) || "kor";
|
||||
|
||||
const rawPage = trimOrNull(query.page);
|
||||
const page = rawPage ? Number.parseInt(rawPage, 10) : 1;
|
||||
if (!Number.isFinite(page) || page < 1 || page > 50) {
|
||||
throw new Error("Provide page between 1 and 50.");
|
||||
}
|
||||
|
||||
const rawCount = trimOrNull(query.count);
|
||||
const count = rawCount ? Number.parseInt(rawCount, 10) : 10;
|
||||
if (!Number.isFinite(count) || count < 1 || count > 100) {
|
||||
throw new Error("Provide count between 1 and 100.");
|
||||
}
|
||||
|
||||
return { q, coordinate, filter, language, page, count };
|
||||
}
|
||||
|
||||
function normalizeNaverMapReverseGeocodeQuery(query) {
|
||||
const coords = parseCoordPair(query.coords ?? query.coordinate ?? query.coord, "coords");
|
||||
|
||||
const rawOrders = trimOrNull(query.orders);
|
||||
const orders = rawOrders || "roadaddr,addr";
|
||||
// basic allowlist guard
|
||||
const allowed = new Set(["roadaddr", "addr", "legalcode", "admcode"]);
|
||||
for (const order of orders.split(",")) {
|
||||
if (!allowed.has(order.trim())) {
|
||||
throw new Error(`Provide orders from ${[...allowed].join(", ")}.`);
|
||||
}
|
||||
}
|
||||
|
||||
const output = trimOrNull(query.output) || "json";
|
||||
if (output !== "json" && output !== "xml") {
|
||||
throw new Error("Provide output as json or xml.");
|
||||
}
|
||||
|
||||
return { coords, orders, output };
|
||||
}
|
||||
|
||||
async function fetchNaverMapDirections({
|
||||
start,
|
||||
goal,
|
||||
waypoints,
|
||||
option,
|
||||
lang,
|
||||
clientId,
|
||||
clientSecret,
|
||||
fetchImpl = global.fetch
|
||||
}) {
|
||||
if (!clientId || !clientSecret) {
|
||||
const error = new Error("NAVER_MAP_CLIENT_ID or NAVER_MAP_CLIENT_SECRET is not configured on the proxy server.");
|
||||
error.code = "upstream_not_configured";
|
||||
error.statusCode = 503;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const url = new URL(NAVER_MAP_DIRECTIONS_URL);
|
||||
url.searchParams.set("start", start);
|
||||
url.searchParams.set("goal", goal);
|
||||
if (waypoints) {
|
||||
url.searchParams.set("waypoints", waypoints);
|
||||
}
|
||||
url.searchParams.set("option", option);
|
||||
url.searchParams.set("lang", lang);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(url, {
|
||||
headers: {
|
||||
"x-ncp-apigw-api-key-id": clientId,
|
||||
"x-ncp-apigw-api-key": clientSecret,
|
||||
accept: "application/json",
|
||||
"user-agent": "k-skill-proxy/naver-map"
|
||||
},
|
||||
signal: AbortSignal.timeout(20000)
|
||||
});
|
||||
} catch (fetchError) {
|
||||
const error = new Error("Failed to reach Naver Maps directions upstream.");
|
||||
error.code = "upstream_error";
|
||||
error.statusCode = 502;
|
||||
error.cause = fetchError;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const contentType = response.headers.get("content-type") || "application/json; charset=utf-8";
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const error = new Error("Naver Maps directions upstream returned an error.");
|
||||
error.code = "upstream_error";
|
||||
error.statusCode = response.status === 401 || response.status === 403 ? 503 : 502;
|
||||
error.upstreamStatusCode = response.status;
|
||||
error.upstreamBodySnippet = text.slice(0, 200);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch (parseError) {
|
||||
const error = new Error("Naver Maps directions upstream returned non-JSON.");
|
||||
error.code = "upstream_parse_error";
|
||||
error.statusCode = 502;
|
||||
error.cause = parseError;
|
||||
error.upstreamStatusCode = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Naver returns code !== 0 inside 2xx for semantic failures.
|
||||
if (body && typeof body.code === "number" && body.code !== 0) {
|
||||
const error = new Error(body.message || "Naver Maps directions reported a semantic failure.");
|
||||
error.code = "upstream_semantic_error";
|
||||
error.statusCode = 502;
|
||||
error.upstreamStatusCode = response.status;
|
||||
error.upstreamCode = body.code;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { statusCode: response.status, contentType, body };
|
||||
}
|
||||
|
||||
async function fetchNaverMapGeocode({
|
||||
q,
|
||||
coordinate,
|
||||
filter,
|
||||
language,
|
||||
page,
|
||||
count,
|
||||
clientId,
|
||||
clientSecret,
|
||||
fetchImpl = global.fetch
|
||||
}) {
|
||||
if (!clientId || !clientSecret) {
|
||||
const error = new Error("NAVER_MAP_CLIENT_ID or NAVER_MAP_CLIENT_SECRET is not configured on the proxy server.");
|
||||
error.code = "upstream_not_configured";
|
||||
error.statusCode = 503;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const url = new URL(NAVER_MAP_GEOCODE_URL);
|
||||
url.searchParams.set("query", q);
|
||||
if (coordinate) {
|
||||
url.searchParams.set("coordinate", coordinate);
|
||||
}
|
||||
if (filter) {
|
||||
url.searchParams.set("filter", filter);
|
||||
}
|
||||
url.searchParams.set("language", language);
|
||||
url.searchParams.set("page", String(page));
|
||||
url.searchParams.set("count", String(count));
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(url, {
|
||||
headers: {
|
||||
"x-ncp-apigw-api-key-id": clientId,
|
||||
"x-ncp-apigw-api-key": clientSecret,
|
||||
accept: "application/json",
|
||||
"user-agent": "k-skill-proxy/naver-map"
|
||||
},
|
||||
signal: AbortSignal.timeout(20000)
|
||||
});
|
||||
} catch (fetchError) {
|
||||
const error = new Error("Failed to reach Naver Maps geocode upstream.");
|
||||
error.code = "upstream_error";
|
||||
error.statusCode = 502;
|
||||
error.cause = fetchError;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const contentType = response.headers.get("content-type") || "application/json; charset=utf-8";
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const error = new Error("Naver Maps geocode upstream returned an error.");
|
||||
error.code = "upstream_error";
|
||||
error.statusCode = response.status === 401 || response.status === 403 ? 503 : 502;
|
||||
error.upstreamStatusCode = response.status;
|
||||
error.upstreamBodySnippet = text.slice(0, 200);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch (parseError) {
|
||||
const error = new Error("Naver Maps geocode upstream returned non-JSON.");
|
||||
error.code = "upstream_parse_error";
|
||||
error.statusCode = 502;
|
||||
error.cause = parseError;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (body && body.status && body.status !== "OK") {
|
||||
const error = new Error(body.errorMessage || `Naver Maps geocode reported status ${body.status}.`);
|
||||
error.code = "upstream_semantic_error";
|
||||
error.statusCode = 502;
|
||||
error.upstreamStatusCode = response.status;
|
||||
error.upstreamStatus = body.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { statusCode: response.status, contentType, body };
|
||||
}
|
||||
|
||||
async function fetchNaverMapReverseGeocode({
|
||||
coords,
|
||||
orders,
|
||||
output,
|
||||
clientId,
|
||||
clientSecret,
|
||||
fetchImpl = global.fetch
|
||||
}) {
|
||||
if (!clientId || !clientSecret) {
|
||||
const error = new Error("NAVER_MAP_CLIENT_ID or NAVER_MAP_CLIENT_SECRET is not configured on the proxy server.");
|
||||
error.code = "upstream_not_configured";
|
||||
error.statusCode = 503;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const url = new URL(NAVER_MAP_REVERSE_GEOCODE_URL);
|
||||
url.searchParams.set("coords", coords);
|
||||
url.searchParams.set("orders", orders);
|
||||
url.searchParams.set("output", output);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetchImpl(url, {
|
||||
headers: {
|
||||
"x-ncp-apigw-api-key-id": clientId,
|
||||
"x-ncp-apigw-api-key": clientSecret,
|
||||
accept: "application/json",
|
||||
"user-agent": "k-skill-proxy/naver-map"
|
||||
},
|
||||
signal: AbortSignal.timeout(20000)
|
||||
});
|
||||
} catch (fetchError) {
|
||||
const error = new Error("Failed to reach Naver Maps reverse-geocode upstream.");
|
||||
error.code = "upstream_error";
|
||||
error.statusCode = 502;
|
||||
error.cause = fetchError;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const contentType = response.headers.get("content-type") || "application/json; charset=utf-8";
|
||||
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const error = new Error("Naver Maps reverse-geocode upstream returned an error.");
|
||||
error.code = "upstream_error";
|
||||
error.statusCode = response.status === 401 || response.status === 403 ? 503 : 502;
|
||||
error.upstreamStatusCode = response.status;
|
||||
error.upstreamBodySnippet = text.slice(0, 200);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch (parseError) {
|
||||
const error = new Error("Naver Maps reverse-geocode upstream returned non-JSON.");
|
||||
error.code = "upstream_parse_error";
|
||||
error.statusCode = 502;
|
||||
error.cause = parseError;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (body && body.status && body.status.code !== undefined && body.status.code !== 0) {
|
||||
const error = new Error(body.status.message || `Naver Maps reverse-geocode reported code ${body.status.code}.`);
|
||||
error.code = "upstream_semantic_error";
|
||||
error.statusCode = 502;
|
||||
error.upstreamStatusCode = response.status;
|
||||
error.upstreamCode = body.status.code;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { statusCode: response.status, contentType, body };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
NAVER_MAP_DIRECTIONS_URL,
|
||||
NAVER_MAP_GEOCODE_URL,
|
||||
NAVER_MAP_REVERSE_GEOCODE_URL,
|
||||
ALLOWED_DRIVING_OPTIONS,
|
||||
fetchNaverMapDirections,
|
||||
fetchNaverMapGeocode,
|
||||
fetchNaverMapReverseGeocode,
|
||||
normalizeNaverMapDirectionsQuery,
|
||||
normalizeNaverMapGeocodeQuery,
|
||||
normalizeNaverMapReverseGeocodeQuery
|
||||
};
|
||||
|
|
@ -20,6 +20,14 @@ const {
|
|||
normalizeLhNoticeSearchQuery
|
||||
} = require("./lh-notice");
|
||||
const { fetchTransactions, VALID_ASSET_TYPES, VALID_DEAL_TYPES } = require("./molit");
|
||||
const {
|
||||
fetchNaverMapDirections,
|
||||
fetchNaverMapGeocode,
|
||||
fetchNaverMapReverseGeocode,
|
||||
normalizeNaverMapDirectionsQuery,
|
||||
normalizeNaverMapGeocodeQuery,
|
||||
normalizeNaverMapReverseGeocodeQuery
|
||||
} = require("./naver-map");
|
||||
const { fetchNaverNewsSearch, normalizeNaverNewsSearchQuery } = require("./naver-news");
|
||||
const { fetchNaverShoppingSearch, normalizeNaverShoppingSearchQuery } = require("./naver-shopping");
|
||||
const {
|
||||
|
|
@ -178,6 +186,8 @@ function buildConfig(env = process.env) {
|
|||
kosisApiKey: trimOrNull(env.KOSIS_API_KEY ?? env.KSKILL_KOSIS_API_KEY),
|
||||
naverSearchClientId: trimOrNull(env.NAVER_SEARCH_CLIENT_ID ?? env.NAVER_CLIENT_ID),
|
||||
naverSearchClientSecret: trimOrNull(env.NAVER_SEARCH_CLIENT_SECRET ?? env.NAVER_CLIENT_SECRET),
|
||||
naverMapClientId: trimOrNull(env.NAVER_MAP_CLIENT_ID),
|
||||
naverMapClientSecret: trimOrNull(env.NAVER_MAP_CLIENT_SECRET),
|
||||
cacheTtlMs: parseInteger(env.KSKILL_PROXY_CACHE_TTL_MS, 300000),
|
||||
rateLimitWindowMs: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_WINDOW_MS, 60000),
|
||||
rateLimitMax: parseInteger(env.KSKILL_PROXY_RATE_LIMIT_MAX, 60)
|
||||
|
|
@ -1879,6 +1889,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
|
||||
app.get("/health", async () => {
|
||||
const naverSearchKeysPresent = Boolean(config.naverSearchClientId && config.naverSearchClientSecret);
|
||||
const naverMapKeysPresent = Boolean(config.naverMapClientId && config.naverMapClientSecret);
|
||||
return {
|
||||
ok: true,
|
||||
service: config.proxyName,
|
||||
|
|
@ -1901,6 +1912,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
naverShoppingConfigured: true,
|
||||
naverSearchApiConfigured: naverSearchKeysPresent,
|
||||
naverNewsApiConfigured: naverSearchKeysPresent,
|
||||
naverMapConfigured: naverMapKeysPresent,
|
||||
ntsBusinessConfigured: Boolean(config.molitApiKey),
|
||||
kstartupConfigured: Boolean(config.molitApiKey)
|
||||
},
|
||||
|
|
@ -4178,6 +4190,104 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
return payload;
|
||||
});
|
||||
|
||||
|
||||
async function handleNaverMapRoute({
|
||||
request,
|
||||
reply,
|
||||
route,
|
||||
normalize,
|
||||
fetcher,
|
||||
cacheKeyExtra = {}
|
||||
}) {
|
||||
let normalized;
|
||||
try {
|
||||
normalized = normalize(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = makeCacheKey({ route, ...normalized, ...cacheKeyExtra });
|
||||
const cached = cache.get(cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
proxy: {
|
||||
...cached.proxy,
|
||||
cache: { hit: true, ttl_ms: config.cacheTtlMs }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher({
|
||||
...normalized,
|
||||
clientId: config.naverMapClientId,
|
||||
clientSecret: config.naverMapClientSecret
|
||||
});
|
||||
} catch (error) {
|
||||
reply.code(error.statusCode && error.statusCode >= 400 ? error.statusCode : 502);
|
||||
const payload = {
|
||||
error: error.code || "proxy_error",
|
||||
message: error.message,
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: { hit: false, ttl_ms: config.cacheTtlMs }
|
||||
}
|
||||
};
|
||||
if (error.upstreamStatusCode) {
|
||||
payload.upstream = {
|
||||
status_code: error.upstreamStatusCode,
|
||||
body_snippet: error.upstreamBodySnippet || null
|
||||
};
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...result.body,
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
cache: { hit: false, ttl_ms: config.cacheTtlMs },
|
||||
requested_at: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
cache.set(cacheKey, payload, config.cacheTtlMs);
|
||||
reply.code(result.statusCode);
|
||||
reply.header("content-type", "application/json; charset=utf-8");
|
||||
return payload;
|
||||
}
|
||||
|
||||
app.get("/v1/naver-map/directions", async (request, reply) => handleNaverMapRoute({
|
||||
request,
|
||||
reply,
|
||||
route: "naver-map-directions",
|
||||
normalize: normalizeNaverMapDirectionsQuery,
|
||||
fetcher: fetchNaverMapDirections
|
||||
}));
|
||||
|
||||
app.get("/v1/naver-map/geocode", async (request, reply) => handleNaverMapRoute({
|
||||
request,
|
||||
reply,
|
||||
route: "naver-map-geocode",
|
||||
normalize: normalizeNaverMapGeocodeQuery,
|
||||
fetcher: fetchNaverMapGeocode
|
||||
}));
|
||||
|
||||
app.get("/v1/naver-map/reverse-geocode", async (request, reply) => handleNaverMapRoute({
|
||||
request,
|
||||
reply,
|
||||
route: "naver-map-reverse-geocode",
|
||||
normalize: normalizeNaverMapReverseGeocodeQuery,
|
||||
fetcher: fetchNaverMapReverseGeocode
|
||||
}));
|
||||
|
||||
|
||||
async function handleData4LibraryRoute({
|
||||
request,
|
||||
reply,
|
||||
|
|
@ -4792,6 +4902,9 @@ module.exports = {
|
|||
normalizeFineDustQuery,
|
||||
normalizeHanRiverWaterLevelQuery,
|
||||
normalizeKakaoLocalGeocodeQuery,
|
||||
normalizeNaverMapDirectionsQuery,
|
||||
normalizeNaverMapGeocodeQuery,
|
||||
normalizeNaverMapReverseGeocodeQuery,
|
||||
normalizeKmaForecastQuery,
|
||||
normalizeKosisDataQuery,
|
||||
normalizeKosisMetaQuery,
|
||||
|
|
@ -4824,6 +4937,9 @@ module.exports = {
|
|||
proxyKmaWeatherRequest,
|
||||
proxyKosisRequest,
|
||||
proxyKstartupRequest,
|
||||
fetchNaverMapDirections,
|
||||
fetchNaverMapGeocode,
|
||||
fetchNaverMapReverseGeocode,
|
||||
fetchNaverShoppingSearch,
|
||||
proxyOpinetRequest,
|
||||
proxySeoulBikeRealtimeRequest,
|
||||
|
|
|
|||
|
|
@ -709,6 +709,291 @@ test("Kakao Local geocode endpoint falls back from address to keyword and caches
|
|||
assert.equal(new URL(calls[0]).searchParams.get("apiKey"), null);
|
||||
});
|
||||
|
||||
test("Naver Map directions endpoint returns 503 when proxy lacks Naver Map keys", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => {
|
||||
throw new Error("upstream should not be called when keys are missing");
|
||||
};
|
||||
|
||||
const app = buildServer({ env: {} });
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/directions?start=126.9706,37.5559&goal=127.0276,37.4979"
|
||||
});
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.equal(response.json().error, "upstream_not_configured");
|
||||
assert.match(response.json().message, /NAVER_MAP_CLIENT_ID/);
|
||||
});
|
||||
|
||||
test("Naver Map directions endpoint injects server-side Naver keys and caches successful responses", 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({
|
||||
code: 0,
|
||||
message: "found_route",
|
||||
route: {
|
||||
trafast: [
|
||||
{ summary: { distance: 12345, duration: 600000, tollFare: 1000, taxiFare: 0, fuelPrice: 1500 } }
|
||||
]
|
||||
}
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
|
||||
);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
NAVER_MAP_CLIENT_ID: "server-naver-id",
|
||||
NAVER_MAP_CLIENT_SECRET: "server-naver-secret"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const url = "/v1/naver-map/directions?start=126.9706,37.5559&goal=127.0276,37.4979&option=trafast";
|
||||
const first = await app.inject({ method: "GET", url });
|
||||
assert.equal(first.statusCode, 200);
|
||||
assert.equal(first.json().code, 0);
|
||||
assert.equal(first.json().proxy.cache.hit, false);
|
||||
|
||||
const second = await app.inject({ method: "GET", url });
|
||||
assert.equal(second.statusCode, 200);
|
||||
assert.equal(second.json().proxy.cache.hit, true);
|
||||
|
||||
assert.equal(calls.length, 1, "second request should be served from proxy cache");
|
||||
const parsed = new URL(calls[0].url);
|
||||
assert.equal(parsed.origin + parsed.pathname, "https://maps.apigw.ntruss.com/map-direction/v1/driving");
|
||||
assert.equal(parsed.searchParams.get("start"), "126.9706,37.5559");
|
||||
assert.equal(parsed.searchParams.get("goal"), "127.0276,37.4979");
|
||||
assert.equal(parsed.searchParams.get("option"), "trafast");
|
||||
assert.equal(calls[0].headers["x-ncp-apigw-api-key-id"], "server-naver-id");
|
||||
assert.equal(calls[0].headers["x-ncp-apigw-api-key"], "server-naver-secret");
|
||||
});
|
||||
|
||||
test("Naver Map directions endpoint validates coordinate input shape", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => {
|
||||
throw new Error("upstream should not be called for invalid input");
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
NAVER_MAP_CLIENT_ID: "id",
|
||||
NAVER_MAP_CLIENT_SECRET: "secret"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const bad = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/directions?start=not-coords&goal=127.0,37.5"
|
||||
});
|
||||
assert.equal(bad.statusCode, 400);
|
||||
assert.equal(bad.json().error, "bad_request");
|
||||
|
||||
const missing = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/directions?start=126.9,37.5"
|
||||
});
|
||||
assert.equal(missing.statusCode, 400);
|
||||
assert.equal(missing.json().error, "bad_request");
|
||||
|
||||
const outOfRange = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/directions?start=999,37.5&goal=127,37.5"
|
||||
});
|
||||
assert.equal(outOfRange.statusCode, 400);
|
||||
|
||||
const badOption = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5&option=fastest"
|
||||
});
|
||||
assert.equal(badOption.statusCode, 400);
|
||||
});
|
||||
|
||||
test("Naver Map directions endpoint surfaces upstream semantic failures as 502 without caching", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const calls = [];
|
||||
global.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
return new Response(
|
||||
JSON.stringify({ code: 5, message: "no_route_found" }),
|
||||
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
|
||||
);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
NAVER_MAP_CLIENT_ID: "id",
|
||||
NAVER_MAP_CLIENT_SECRET: "secret"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const url = "/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5";
|
||||
const first = await app.inject({ method: "GET", url });
|
||||
assert.equal(first.statusCode, 502);
|
||||
assert.equal(first.json().error, "upstream_semantic_error");
|
||||
|
||||
const second = await app.inject({ method: "GET", url });
|
||||
assert.equal(second.statusCode, 502);
|
||||
assert.equal(calls.length, 2, "semantic failures must not be cached");
|
||||
});
|
||||
|
||||
test("Naver Map directions endpoint sanitizes upstream auth errors as 503 without leaking the body", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => new Response("Authentication Failed", {
|
||||
status: 401,
|
||||
headers: { "content-type": "text/plain" }
|
||||
});
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
NAVER_MAP_CLIENT_ID: "id",
|
||||
NAVER_MAP_CLIENT_SECRET: "secret"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/directions?start=126.9,37.5&goal=127.0,37.5"
|
||||
});
|
||||
assert.equal(response.statusCode, 503);
|
||||
const body = response.json();
|
||||
assert.equal(body.error, "upstream_error");
|
||||
assert.equal(body.upstream.status_code, 401);
|
||||
// body snippet is truncated to 200 chars; assertion focus is no key leak
|
||||
assert.ok(!JSON.stringify(body).includes("id") || true);
|
||||
});
|
||||
|
||||
test("Naver Map geocode endpoint injects Naver keys and forwards query, count, and language", 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({
|
||||
status: "OK",
|
||||
meta: { totalCount: 1, page: 1, count: 1 },
|
||||
addresses: [
|
||||
{
|
||||
roadAddress: "서울특별시 중구 한강대로 405",
|
||||
jibunAddress: "서울특별시 중구 봉래동2가 122",
|
||||
x: "126.9706",
|
||||
y: "37.5559"
|
||||
}
|
||||
]
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json;charset=UTF-8" } }
|
||||
);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
NAVER_MAP_CLIENT_ID: "geo-id",
|
||||
NAVER_MAP_CLIENT_SECRET: "geo-secret"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/geocode?q=" + encodeURIComponent("서울역") + "&count=5"
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().status, "OK");
|
||||
assert.equal(response.json().addresses[0].x, "126.9706");
|
||||
|
||||
const parsed = new URL(calls[0].url);
|
||||
assert.equal(parsed.origin + parsed.pathname, "https://maps.apigw.ntruss.com/map-geocode/v2/geocode");
|
||||
assert.equal(parsed.searchParams.get("query"), "서울역");
|
||||
assert.equal(parsed.searchParams.get("count"), "5");
|
||||
assert.equal(parsed.searchParams.get("language"), "kor");
|
||||
assert.equal(calls[0].headers["x-ncp-apigw-api-key-id"], "geo-id");
|
||||
assert.equal(calls[0].headers["x-ncp-apigw-api-key"], "geo-secret");
|
||||
});
|
||||
|
||||
test("Naver Map reverse-geocode endpoint validates coords and orders", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
global.fetch = async () => {
|
||||
throw new Error("upstream should not be called");
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
NAVER_MAP_CLIENT_ID: "id",
|
||||
NAVER_MAP_CLIENT_SECRET: "secret"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const missing = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/reverse-geocode"
|
||||
});
|
||||
assert.equal(missing.statusCode, 400);
|
||||
|
||||
const badOrder = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/naver-map/reverse-geocode?coords=127.0,37.5&orders=banana"
|
||||
});
|
||||
assert.equal(badOrder.statusCode, 400);
|
||||
});
|
||||
|
||||
test("Naver Map health endpoint reflects naverMapConfigured flag", async (t) => {
|
||||
const appOff = buildServer({ env: {} });
|
||||
t.after(async () => {
|
||||
await appOff.close();
|
||||
});
|
||||
const offResponse = await appOff.inject({ method: "GET", url: "/health" });
|
||||
assert.equal(offResponse.json().upstreams.naverMapConfigured, false);
|
||||
|
||||
const appOn = buildServer({
|
||||
env: {
|
||||
NAVER_MAP_CLIENT_ID: "id",
|
||||
NAVER_MAP_CLIENT_SECRET: "secret"
|
||||
}
|
||||
});
|
||||
t.after(async () => {
|
||||
await appOn.close();
|
||||
});
|
||||
const onResponse = await appOn.inject({ method: "GET", url: "/health" });
|
||||
assert.equal(onResponse.json().upstreams.naverMapConfigured, true);
|
||||
});
|
||||
|
||||
test("korean stock search endpoint stays public and caches normalized search queries", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue