mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Remove client-side Seoul subway key setup
Route Seoul subway arrival lookups through k-skill-proxy so the hosted proxy owns the Seoul Open Data upstream key and end users only need the proxy base URL. Add proxy route coverage, update skill/docs guidance, and align setup materials with the hosted proxy flow used for fine dust. Constraint: Must keep the proxy public, read-only, and dependency-free Constraint: Must satisfy TDD-first verification and ship on feature/#35 targeting dev Rejected: Add a separate client helper package | unnecessary extra layer for a single proxy route Rejected: Keep SEOUL_OPEN_API_KEY as an end-user requirement | defeats the issue goal Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep the Seoul subway proxy surface limited to station-arrival passthrough unless tests/docs expand the contract Tested: npm run ci; local proxy runtime on 127.0.0.1:4120 for /health and /v1/seoul-subway/arrival on 2026-03-31 with an invalid upstream key Not-tested: Live success response with a valid Seoul Open API key Related: #35
This commit is contained in:
parent
ef2c69b81c
commit
fbc004af9d
12 changed files with 290 additions and 43 deletions
|
|
@ -21,7 +21,7 @@ Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
|
|||
| SRT 예매 | 열차 조회, 예약, 예약 확인, 취소 | 필요 | [SRT 예매 가이드](docs/features/srt-booking.md) |
|
||||
| KTX 예매 | Dynapath anti-bot 대응 helper 로 KTX/Korail 열차 조회, 예약, 예약 확인, 취소 | 필요 | [KTX 예매 가이드](docs/features/ktx-booking.md) |
|
||||
| 카카오톡 Mac CLI | macOS에서 kakaocli로 대화 조회, 검색, 테스트 전송, 확인 후 실제 전송 | 불필요 | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
|
||||
| 서울 지하철 도착정보 조회 | 역 기준 실시간 도착 예정 열차 확인 | 필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
|
||||
| 서울 지하철 도착정보 조회 | `k-skill-proxy` 경유로 역 기준 실시간 도착 예정 열차 확인 | 불필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
|
||||
| 사용자 위치 미세먼지 조회 | `k-skill-proxy` 로 현재 위치 또는 지역 fallback 기준 PM10/PM2.5 확인 | 불필요 | [사용자 위치 미세먼지 조회 가이드](docs/features/fine-dust-location.md) |
|
||||
| KBO 경기 결과 조회 | 날짜별 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
|
||||
| K리그 경기 결과 조회 | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ client/skill -> k-skill-proxy -> upstream public API
|
|||
|
||||
- `GET /health`
|
||||
- `GET /v1/fine-dust/report`
|
||||
- `GET /v1/seoul-subway/arrival`
|
||||
- `GET /B552584/:service/:operation` (허용된 AirKorea route passthrough)
|
||||
|
||||
## 권장 환경변수
|
||||
|
|
@ -27,6 +28,7 @@ client/skill -> k-skill-proxy -> upstream public API
|
|||
프록시 서버 쪽:
|
||||
|
||||
- `AIR_KOREA_OPEN_API_KEY=...`
|
||||
- `SEOUL_OPEN_API_KEY=...`
|
||||
- `KSKILL_PROXY_PORT=4020`
|
||||
|
||||
## PM2 + cloudflared
|
||||
|
|
@ -54,6 +56,13 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/fine-dust/report' \
|
|||
--data-urlencode 'regionHint=서울 강남구'
|
||||
```
|
||||
|
||||
서울 지하철 도착정보 endpoint:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/seoul-subway/arrival' \
|
||||
--data-urlencode 'stationName=강남'
|
||||
```
|
||||
|
||||
AirKorea passthrough endpoint:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -5,23 +5,25 @@
|
|||
- 역 기준 실시간 도착 예정 열차 조회
|
||||
- 상/하행 또는 외/내선 정보 확인
|
||||
- 첫 번째/두 번째 도착 메시지 확인
|
||||
- 개인 OpenAPI key 없이 `k-skill-proxy` 경유 조회
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- [공통 설정 가이드](../setup.md) 완료
|
||||
- [보안/시크릿 정책](../security-and-secrets.md) 확인
|
||||
- 서울 열린데이터 광장 API key
|
||||
- optional override: `KSKILL_PROXY_BASE_URL`
|
||||
|
||||
## 필요한 환경변수
|
||||
|
||||
- `SEOUL_OPEN_API_KEY`
|
||||
- `KSKILL_PROXY_BASE_URL` (optional override, 기본값 `https://k-skill-proxy.nomadamas.org`)
|
||||
|
||||
### Credential resolution order
|
||||
사용자가 서울 열린데이터 광장 OpenAPI key를 직접 발급할 필요가 없다. hosted proxy가 upstream key를 서버에서만 관리한다.
|
||||
|
||||
1. **이미 환경변수에 있으면** 그대로 사용한다.
|
||||
2. **에이전트가 자체 secret vault(1Password CLI, Bitwarden CLI, macOS Keychain 등)를 사용 중이면** 거기서 꺼내 환경변수로 주입해도 된다.
|
||||
3. **`~/.config/k-skill/secrets.env`** (기본 fallback) — plain dotenv 파일, 퍼미션 `0600`.
|
||||
4. **아무것도 없으면** 유저에게 물어서 2 또는 3에 저장한다.
|
||||
### Proxy resolution order
|
||||
|
||||
1. **`KSKILL_PROXY_BASE_URL` 가 있으면** 그 값을 사용합니다.
|
||||
2. **없으면** hosted proxy `https://k-skill-proxy.nomadamas.org` 를 사용합니다.
|
||||
3. **직접 proxy를 운영하는 경우에만** proxy 서버 upstream key 설정을 별도로 구성합니다.
|
||||
|
||||
## 입력값
|
||||
|
||||
|
|
@ -30,14 +32,24 @@
|
|||
|
||||
## 기본 흐름
|
||||
|
||||
1. `SEOUL_OPEN_API_KEY` 가 없으면 credential resolution order에 따라 확보합니다.
|
||||
3. 역명 기준으로 실시간 도착정보를 조회합니다.
|
||||
4. 호선, 진행 방향, 도착 메시지, 조회 시점을 함께 요약합니다.
|
||||
1. `KSKILL_PROXY_BASE_URL` 가 있으면 그 값을 사용하고, 없으면 hosted proxy를 기본값으로 사용합니다.
|
||||
2. `/v1/seoul-subway/arrival?stationName=...` 로 역명 기준 실시간 도착정보를 조회합니다.
|
||||
3. 호선, 진행 방향, 도착 메시지, 조회 시점을 함께 요약합니다.
|
||||
|
||||
## 예시
|
||||
|
||||
```bash
|
||||
curl -s "http://swopenAPI.seoul.go.kr/api/subway/${SEOUL_OPEN_API_KEY}/json/realtimeStationArrival/0/8/강남"
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/seoul-subway/arrival' \
|
||||
--data-urlencode 'stationName=강남'
|
||||
```
|
||||
|
||||
범위를 줄이거나 늘리고 싶으면:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/seoul-subway/arrival' \
|
||||
--data-urlencode 'stationName=서울역' \
|
||||
--data-urlencode 'startIndex=0' \
|
||||
--data-urlencode 'endIndex=4'
|
||||
```
|
||||
|
||||
## 주의할 점
|
||||
|
|
@ -45,3 +57,4 @@ curl -s "http://swopenAPI.seoul.go.kr/api/subway/${SEOUL_OPEN_API_KEY}/json/real
|
|||
- 실시간 데이터라 몇 초 단위로 바뀔 수 있습니다.
|
||||
- 역명 표기가 다르면 결과가 비어 있을 수 있습니다.
|
||||
- 일일 호출 제한이나 quota 초과 가능성이 있습니다.
|
||||
- self-host proxy 설정은 [k-skill 프록시 서버 가이드](k-skill-proxy.md)를 봅니다.
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ KSKILL_SRT_ID=replace-me
|
|||
KSKILL_SRT_PASSWORD=replace-me
|
||||
KSKILL_KTX_ID=replace-me
|
||||
KSKILL_KTX_PASSWORD=replace-me
|
||||
SEOUL_OPEN_API_KEY=replace-me
|
||||
AIR_KOREA_OPEN_API_KEY=replace-me
|
||||
KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org
|
||||
```
|
||||
|
|
@ -58,8 +57,9 @@ KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org
|
|||
- `KSKILL_SRT_PASSWORD`
|
||||
- `KSKILL_KTX_ID`
|
||||
- `KSKILL_KTX_PASSWORD`
|
||||
- `SEOUL_OPEN_API_KEY`
|
||||
- `AIR_KOREA_OPEN_API_KEY`
|
||||
- `KSKILL_PROXY_BASE_URL`
|
||||
|
||||
프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY` 도 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다.
|
||||
|
||||
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 공통 설정 가이드
|
||||
|
||||
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 서울 지하철 도착정보 조회, 미세먼지 조회)을 사용하려면 이 절차를 진행하면 된다.
|
||||
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, self-host 프록시 운영용 서울 지하철/미세먼지 upstream key)을 사용하려면 이 절차를 진행하면 된다.
|
||||
|
||||
## Credential resolution order
|
||||
|
||||
|
|
@ -24,7 +24,6 @@ KSKILL_SRT_ID=replace-me
|
|||
KSKILL_SRT_PASSWORD=replace-me
|
||||
KSKILL_KTX_ID=replace-me
|
||||
KSKILL_KTX_PASSWORD=replace-me
|
||||
SEOUL_OPEN_API_KEY=replace-me
|
||||
AIR_KOREA_OPEN_API_KEY=replace-me
|
||||
KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org
|
||||
EOF
|
||||
|
|
@ -52,7 +51,7 @@ bash scripts/check-setup.sh
|
|||
| --- | --- |
|
||||
| SRT 예매 | `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD` |
|
||||
| KTX 예매 | `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD` |
|
||||
| 서울 지하철 도착정보 조회 | `SEOUL_OPEN_API_KEY` |
|
||||
| 서울 지하철 도착정보 조회 | `KSKILL_PROXY_BASE_URL` |
|
||||
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
|
||||
|
||||
## 다음에 볼 문서
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@ KSKILL_SRT_ID=replace-me
|
|||
KSKILL_SRT_PASSWORD=replace-me
|
||||
KSKILL_KTX_ID=replace-me
|
||||
KSKILL_KTX_PASSWORD=replace-me
|
||||
SEOUL_OPEN_API_KEY=replace-me
|
||||
AIR_KOREA_OPEN_API_KEY=replace-me
|
||||
KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ KSKILL_SRT_ID=replace-me
|
|||
KSKILL_SRT_PASSWORD=replace-me
|
||||
KSKILL_KTX_ID=replace-me
|
||||
KSKILL_KTX_PASSWORD=replace-me
|
||||
SEOUL_OPEN_API_KEY=replace-me
|
||||
AIR_KOREA_OPEN_API_KEY=replace-me
|
||||
KSKILL_PROXY_BASE_URL=https://k-skill-proxy.nomadamas.org
|
||||
EOF
|
||||
chmod 0600 ~/.config/k-skill/secrets.env
|
||||
```
|
||||
|
|
@ -83,8 +83,8 @@ chmod 0600 ~/.config/k-skill/secrets.env
|
|||
|
||||
- SRT: `KSKILL_SRT_ID`, `KSKILL_SRT_PASSWORD`
|
||||
- KTX: `KSKILL_KTX_ID`, `KSKILL_KTX_PASSWORD`
|
||||
- 서울 지하철: `SEOUL_OPEN_API_KEY`
|
||||
- 사용자 위치 미세먼지 조회: `AIR_KOREA_OPEN_API_KEY`
|
||||
- 서울 지하철: `KSKILL_PROXY_BASE_URL`
|
||||
- 사용자 위치 미세먼지 조회: `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY`
|
||||
|
||||
시크릿이 비어 있다는 이유로 다른 서비스나 비공식 우회 경로를 자동 선택하지 않는다.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
# k-skill-proxy
|
||||
|
||||
`k-skill`용 Fastify 기반 프록시 서버입니다. 지금은 AirKorea 미세먼지 조회를 먼저 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
|
||||
`k-skill`용 Fastify 기반 프록시 서버입니다. AirKorea 미세먼지 조회와 서울 지하철 실시간 도착정보를 먼저 감싸고, 이후 무료/공공 API adapter를 추가하는 베이스로 씁니다.
|
||||
|
||||
## 현재 제공 엔드포인트
|
||||
|
||||
- `GET /health`
|
||||
- `GET /v1/fine-dust/report`
|
||||
- `GET /v1/seoul-subway/arrival`
|
||||
|
||||
## 환경변수
|
||||
|
||||
- `AIR_KOREA_OPEN_API_KEY` — 프록시 서버 쪽 AirKorea upstream key
|
||||
- `SEOUL_OPEN_API_KEY` — 프록시 서버 쪽 서울 열린데이터 광장 upstream key
|
||||
- `KSKILL_PROXY_HOST` — 기본 `127.0.0.1`
|
||||
- `KSKILL_PROXY_PORT` — 기본 `4020`
|
||||
- `KSKILL_PROXY_CACHE_TTL_MS` — 기본 `300000`
|
||||
|
|
@ -26,6 +28,13 @@ node packages/k-skill-proxy/src/server.js
|
|||
|
||||
환경변수(`AIR_KOREA_OPEN_API_KEY` 등)가 이미 설정되어 있거나 `~/.config/k-skill/secrets.env`를 source한 상태에서 실행한다.
|
||||
|
||||
서울 지하철 도착정보 예시:
|
||||
|
||||
```bash
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/seoul-subway/arrival' \
|
||||
--data-urlencode 'stationName=강남'
|
||||
```
|
||||
|
||||
## PM2 실행
|
||||
|
||||
루트의 `ecosystem.config.cjs` + `scripts/run-k-skill-proxy.sh` 조합을 사용하면 재부팅 이후에도 같은 환경변수로 다시 올라옵니다.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
const crypto = require("node:crypto");
|
||||
const Fastify = require("fastify");
|
||||
const { fetchFineDustReport } = require("./airkorea");
|
||||
const UPSTREAM_BASE_URL = "http://apis.data.go.kr";
|
||||
const AIR_KOREA_UPSTREAM_BASE_URL = "http://apis.data.go.kr";
|
||||
const SEOUL_OPEN_API_BASE_URL = "http://swopenapi.seoul.go.kr";
|
||||
const ALLOWED_AIRKOREA_ROUTES = new Map([
|
||||
["MsrstnInfoInqireSvc", new Set(["getMsrstnList", "getNearbyMsrstnList", "getTMStdrCrdnt"])],
|
||||
["ArpltnInforInqireSvc", new Set(["getMsrstnAcctoRltmMesureDnsty", "getCtprvnRltmMesureDnsty"])],
|
||||
|
|
@ -40,6 +41,7 @@ function buildConfig(env = process.env) {
|
|||
port: parseInteger(env.KSKILL_PROXY_PORT, 4020),
|
||||
proxyName: env.KSKILL_PROXY_NAME || "k-skill-proxy",
|
||||
airKoreaApiKey: trimOrNull(env.AIR_KOREA_OPEN_API_KEY),
|
||||
seoulOpenApiKey: trimOrNull(env.SEOUL_OPEN_API_KEY),
|
||||
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)
|
||||
|
|
@ -120,6 +122,26 @@ function normalizeFineDustQuery(query) {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeSeoulSubwayQuery(query) {
|
||||
const stationName = trimOrNull(query.stationName ?? query.station_name ?? query.station);
|
||||
if (!stationName) {
|
||||
throw new Error("Provide stationName.");
|
||||
}
|
||||
|
||||
const startIndex = parseInteger(query.startIndex ?? query.start_index, 0);
|
||||
const endIndex = parseInteger(query.endIndex ?? query.end_index, 8);
|
||||
|
||||
if (startIndex < 0 || endIndex < startIndex) {
|
||||
throw new Error("Provide valid startIndex and endIndex.");
|
||||
}
|
||||
|
||||
return {
|
||||
stationName,
|
||||
startIndex,
|
||||
endIndex
|
||||
};
|
||||
}
|
||||
|
||||
function isAllowedAirKoreaRoute(service, operation) {
|
||||
return ALLOWED_AIRKOREA_ROUTES.get(service)?.has(operation) || false;
|
||||
}
|
||||
|
|
@ -147,7 +169,7 @@ async function proxyAirKoreaRequest({ service, operation, query, serviceKey, fet
|
|||
};
|
||||
}
|
||||
|
||||
const url = new URL(`${UPSTREAM_BASE_URL}/B552584/${service}/${operation}`);
|
||||
const url = new URL(`${AIR_KOREA_UPSTREAM_BASE_URL}/B552584/${service}/${operation}`);
|
||||
for (const [key, value] of Object.entries(query || {})) {
|
||||
if (value === undefined || value === null || value === "" || key === "serviceKey") {
|
||||
continue;
|
||||
|
|
@ -172,6 +194,40 @@ async function proxyAirKoreaRequest({ service, operation, query, serviceKey, fet
|
|||
};
|
||||
}
|
||||
|
||||
async function proxySeoulSubwayRequest({
|
||||
stationName,
|
||||
startIndex = 0,
|
||||
endIndex = 8,
|
||||
apiKey,
|
||||
fetchImpl = global.fetch
|
||||
}) {
|
||||
if (!apiKey) {
|
||||
return {
|
||||
statusCode: 503,
|
||||
contentType: "application/json; charset=utf-8",
|
||||
body: JSON.stringify({
|
||||
error: "upstream_not_configured",
|
||||
message: "SEOUL_OPEN_API_KEY is not configured on the proxy server."
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
const encodedStationName = encodeURIComponent(stationName);
|
||||
const url = new URL(
|
||||
`${SEOUL_OPEN_API_BASE_URL}/api/subway/${apiKey}/json/realtimeStationArrival/${startIndex}/${endIndex}/${encodedStationName}`
|
||||
);
|
||||
|
||||
const response = await fetchImpl(url, {
|
||||
signal: AbortSignal.timeout(20000)
|
||||
});
|
||||
|
||||
return {
|
||||
statusCode: response.status,
|
||||
contentType: response.headers.get("content-type") || "application/json; charset=utf-8",
|
||||
body: await response.text()
|
||||
};
|
||||
}
|
||||
|
||||
function buildServer({ env = process.env, provider = null } = {}) {
|
||||
const config = buildConfig(env);
|
||||
const cache = createMemoryCache();
|
||||
|
|
@ -202,7 +258,8 @@ function buildServer({ env = process.env, provider = null } = {}) {
|
|||
service: config.proxyName,
|
||||
port: config.port,
|
||||
upstreams: {
|
||||
airKoreaConfigured: Boolean(config.airKoreaApiKey)
|
||||
airKoreaConfigured: Boolean(config.airKoreaApiKey),
|
||||
seoulOpenApiConfigured: Boolean(config.seoulOpenApiKey)
|
||||
},
|
||||
auth: {
|
||||
tokenRequired: false
|
||||
|
|
@ -284,6 +341,39 @@ function buildServer({ env = process.env, provider = null } = {}) {
|
|||
return payload;
|
||||
});
|
||||
|
||||
app.get("/v1/seoul-subway/arrival", async (request, reply) => {
|
||||
let normalized;
|
||||
|
||||
try {
|
||||
normalized = normalizeSeoulSubwayQuery(request.query || {});
|
||||
} catch (error) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: "bad_request",
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
|
||||
const upstream = await proxySeoulSubwayRequest({
|
||||
...normalized,
|
||||
apiKey: config.seoulOpenApiKey
|
||||
});
|
||||
|
||||
reply.code(upstream.statusCode);
|
||||
reply.header("content-type", upstream.contentType);
|
||||
|
||||
if (!upstream.contentType.includes("json")) {
|
||||
return upstream.body;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(upstream.body);
|
||||
payload.proxy = {
|
||||
name: config.proxyName,
|
||||
requested_at: new Date().toISOString()
|
||||
};
|
||||
return payload;
|
||||
});
|
||||
|
||||
app.setErrorHandler((error, request, reply) => {
|
||||
request.log.error(error);
|
||||
const statusCode = error.statusCode && error.statusCode >= 400 ? error.statusCode : 500;
|
||||
|
|
@ -324,6 +414,8 @@ module.exports = {
|
|||
buildConfig,
|
||||
buildServer,
|
||||
normalizeFineDustQuery,
|
||||
normalizeSeoulSubwayQuery,
|
||||
proxyAirKoreaRequest,
|
||||
proxySeoulSubwayRequest,
|
||||
startServer
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { buildServer, proxyAirKoreaRequest } = require("../src/server");
|
||||
const { buildServer, proxyAirKoreaRequest, proxySeoulSubwayRequest } = require("../src/server");
|
||||
|
||||
test("health endpoint stays public and reports auth/upstream status", async (t) => {
|
||||
const app = buildServer({
|
||||
|
|
@ -24,6 +24,7 @@ test("health endpoint stays public and reports auth/upstream status", async (t)
|
|||
assert.equal(body.ok, true);
|
||||
assert.equal(body.auth.tokenRequired, false);
|
||||
assert.equal(body.upstreams.airKoreaConfigured, false);
|
||||
assert.equal(body.upstreams.seoulOpenApiConfigured, false);
|
||||
});
|
||||
|
||||
test("fine dust endpoint stays publicly callable without proxy auth", async (t) => {
|
||||
|
|
@ -176,3 +177,90 @@ test("public AirKorea passthrough route forwards allowed upstream responses", as
|
|||
assert.equal(response.statusCode, 200);
|
||||
assert.match(response.body, /resultCode/);
|
||||
});
|
||||
|
||||
test("seoul subway endpoint stays publicly callable without proxy auth", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
let calledUrl;
|
||||
global.fetch = async (url) => {
|
||||
calledUrl = String(url);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
errorMessage: {
|
||||
status: 200,
|
||||
code: "INFO-000",
|
||||
message: "정상 처리되었습니다."
|
||||
},
|
||||
realtimeArrivalList: [
|
||||
{
|
||||
statnNm: "강남",
|
||||
trainLineNm: "2호선",
|
||||
updnLine: "내선",
|
||||
arvlMsg2: "전역 출발",
|
||||
arvlMsg3: "역삼",
|
||||
barvlDt: "60"
|
||||
}
|
||||
]
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
SEOUL_OPEN_API_KEY: "seoul-key"
|
||||
}
|
||||
});
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/seoul-subway/arrival?stationName=%EA%B0%95%EB%82%A8"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.equal(response.json().realtimeArrivalList[0].statnNm, "강남");
|
||||
assert.match(calledUrl, /realtimeStationArrival\/0\/8\/%EA%B0%95%EB%82%A8$/);
|
||||
});
|
||||
|
||||
test("seoul subway endpoint returns 503 when proxy server lacks Seoul API key", async (t) => {
|
||||
const app = buildServer();
|
||||
|
||||
t.after(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/seoul-subway/arrival?stationName=%EA%B0%95%EB%82%A8"
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 503);
|
||||
assert.equal(response.json().error, "upstream_not_configured");
|
||||
});
|
||||
|
||||
test("proxySeoulSubwayRequest injects API key and preserves index/station params", async () => {
|
||||
let calledUrl;
|
||||
const result = await proxySeoulSubwayRequest({
|
||||
stationName: "강남",
|
||||
startIndex: "2",
|
||||
endIndex: "5",
|
||||
apiKey: "test-seoul-key",
|
||||
fetchImpl: async (url) => {
|
||||
calledUrl = String(url);
|
||||
return new Response('{"ok":true}', {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.statusCode, 200);
|
||||
assert.match(calledUrl, /\/api\/subway\/test-seoul-key\/json\/realtimeStationArrival\/2\/5\/%EA%B0%95%EB%82%A8$/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -157,6 +157,40 @@ test("repository docs advertise the kakaotalk-mac skill", () => {
|
|||
assert.match(install, /--skill kakaotalk-mac/);
|
||||
});
|
||||
|
||||
test("seoul subway docs default to the public proxy-backed flow", () => {
|
||||
const readme = read("README.md");
|
||||
const setup = read(path.join("docs", "setup.md"));
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const security = read(path.join("docs", "security-and-secrets.md"));
|
||||
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
|
||||
const skill = read(path.join("seoul-subway-arrival", "SKILL.md"));
|
||||
const featureDoc = read(path.join("docs", "features", "seoul-subway-arrival.md"));
|
||||
const proxyDoc = read(path.join("docs", "features", "k-skill-proxy.md"));
|
||||
const proxyReadme = read(path.join("packages", "k-skill-proxy", "README.md"));
|
||||
const secretsExample = read(path.join("examples", "secrets.env.example"));
|
||||
|
||||
assert.match(readme, /\| 서울 지하철 도착정보 조회 \| .* \| 불필요 \|/);
|
||||
assert.match(setup, /\| 서울 지하철 도착정보 조회 \| `KSKILL_PROXY_BASE_URL` \|/);
|
||||
assert.match(install, /--skill seoul-subway-arrival/);
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /KSKILL_PROXY_BASE_URL/);
|
||||
assert.match(doc, /\/v1\/seoul-subway\/arrival/);
|
||||
assert.match(doc, /사용자가 .*OpenAPI key.*직접.*필요가? 없다|개인 API key 없이/i);
|
||||
assert.doesNotMatch(doc, /SEOUL_OPEN_API_KEY/);
|
||||
assert.doesNotMatch(doc, /swopenAPI\.seoul\.go\.kr\/api\/subway\/\$\{SEOUL_OPEN_API_KEY\}/);
|
||||
}
|
||||
|
||||
assert.match(proxyDoc, /GET \/v1\/seoul-subway\/arrival/);
|
||||
assert.match(proxyDoc, /SEOUL_OPEN_API_KEY/);
|
||||
assert.match(proxyReadme, /GET \/v1\/seoul-subway\/arrival/);
|
||||
assert.match(proxyReadme, /SEOUL_OPEN_API_KEY/);
|
||||
assert.match(security, /KSKILL_PROXY_BASE_URL/);
|
||||
assert.match(setupSkill, /서울 지하철: `KSKILL_PROXY_BASE_URL`/);
|
||||
assert.doesNotMatch(secretsExample, /SEOUL_OPEN_API_KEY/);
|
||||
assert.match(secretsExample, /KSKILL_PROXY_BASE_URL=https:\/\/k-skill-proxy\.nomadamas\.org/);
|
||||
});
|
||||
|
||||
test("kakaotalk-mac skill documents safe macOS kakaocli usage", () => {
|
||||
const skillPath = path.join(repoRoot, "kakaotalk-mac", "SKILL.md");
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ metadata:
|
|||
|
||||
## What this skill does
|
||||
|
||||
서울 열린데이터 광장의 실시간 지하철 도착정보 Open API로 역 기준 도착 예정 열차 정보를 요약한다.
|
||||
서울 열린데이터 광장의 실시간 지하철 도착정보 Open API를 `k-skill-proxy` 경유로 조회해 역 기준 도착 예정 열차 정보를 요약한다.
|
||||
|
||||
## When to use
|
||||
|
||||
|
|
@ -22,21 +22,22 @@ metadata:
|
|||
|
||||
## Prerequisites
|
||||
|
||||
- 서울 열린데이터 광장 API key
|
||||
- optional: `jq`
|
||||
- optional override: `KSKILL_PROXY_BASE_URL`
|
||||
|
||||
## Required environment variables
|
||||
|
||||
- `SEOUL_OPEN_API_KEY`
|
||||
- `KSKILL_PROXY_BASE_URL` (optional override, 기본값 `https://k-skill-proxy.nomadamas.org`)
|
||||
|
||||
### Credential resolution order
|
||||
사용자가 개인 서울 열린데이터 광장 OpenAPI key를 직접 발급할 필요가 없다. 기본 hosted proxy가 서버 쪽에 upstream key를 보관한다.
|
||||
|
||||
1. **이미 환경변수에 있으면** 그대로 사용한다.
|
||||
2. **에이전트가 자체 secret vault(1Password CLI, Bitwarden CLI, macOS Keychain 등)를 사용 중이면** 거기서 꺼내 환경변수로 주입해도 된다.
|
||||
3. **`~/.config/k-skill/secrets.env`** (기본 fallback) — plain dotenv 파일, 퍼미션 `0600`.
|
||||
4. **아무것도 없으면** 유저에게 물어서 2 또는 3에 저장한다.
|
||||
### Proxy resolution order
|
||||
|
||||
기본 경로에 저장하는 것은 fallback일 뿐, 강제가 아니다.
|
||||
1. **`KSKILL_PROXY_BASE_URL` 이 있으면** 그 값을 사용한다.
|
||||
2. **없으면** 기본 hosted proxy `https://k-skill-proxy.nomadamas.org` 를 사용한다.
|
||||
3. **직접 proxy를 운영하는 경우에만** proxy 서버 upstream key 설정을 별도로 구성한다.
|
||||
|
||||
클라이언트/사용자 쪽에서 upstream key를 직접 다루지 않는다.
|
||||
|
||||
## Inputs
|
||||
|
||||
|
|
@ -45,20 +46,21 @@ metadata:
|
|||
|
||||
## Workflow
|
||||
|
||||
### 1. Ensure credentials are available
|
||||
### 1. Resolve the proxy base URL
|
||||
|
||||
`SEOUL_OPEN_API_KEY` 환경변수가 설정되어 있는지 확인한다. 없으면 위 credential resolution order에 따라 확보한다.
|
||||
|
||||
시크릿이 없다는 이유로 비공식 미러 API나 다른 출처로 자동 우회하지 않는다.
|
||||
`KSKILL_PROXY_BASE_URL` 이 있으면 그 값을 쓰고, 없으면 hosted proxy를 기본값으로 사용한다.
|
||||
|
||||
### 2. Query the official station arrival endpoint
|
||||
|
||||
서울 실시간 지하철 API는 역명 기준 실시간 도착 정보를 JSON/XML로 제공한다. 기본 질의 예시는 다음 패턴을 쓴다.
|
||||
proxy는 서울 실시간 지하철 API key를 서버에서 주입하고, 역명 기준 실시간 도착정보만 공개 read-only endpoint로 노출한다.
|
||||
|
||||
```bash
|
||||
curl -s "http://swopenAPI.seoul.go.kr/api/subway/${SEOUL_OPEN_API_KEY}/json/realtimeStationArrival/0/8/강남"
|
||||
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/seoul-subway/arrival' \
|
||||
--data-urlencode 'stationName=강남'
|
||||
```
|
||||
|
||||
필요하면 `startIndex`, `endIndex` 로 응답 범위를 조정할 수 있다.
|
||||
|
||||
### 3. Summarize the response
|
||||
|
||||
가능하면 아래 항목만 먼저 요약한다.
|
||||
|
|
@ -77,15 +79,16 @@ curl -s "http://swopenAPI.seoul.go.kr/api/subway/${SEOUL_OPEN_API_KEY}/json/real
|
|||
|
||||
- 요청 역의 도착 예정 열차가 정리되어 있다
|
||||
- live data 기준 시점이 명시되어 있다
|
||||
- key가 노출되지 않았다
|
||||
- upstream key가 클라이언트에 노출되지 않았다
|
||||
|
||||
## Failure modes
|
||||
|
||||
- API key 미설정
|
||||
- proxy upstream key 미설정
|
||||
- quota 초과
|
||||
- 역명 표기 불일치
|
||||
|
||||
## Notes
|
||||
|
||||
- 서울 열린데이터 광장 가이드는 실시간 지하철 Open API에 일일 호출 제한이 있을 수 있다고 안내한다
|
||||
- proxy 운영/환경변수 설정은 `docs/features/k-skill-proxy.md` 를 참고한다
|
||||
- endpoint path는 API 버전 변경 가능성이 있으므로 실패 시 dataset console의 최신 샘플 URL을 다시 확인한다
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue