feat(k-skill-proxy): 생활쓰레기 페이지네이션 검증 및 문서 보강

- /v1/household-waste/info에 pageNo·numOfRows 필수, 값은 1·100만 허용(미충족·비정수 문자열은 400)
- validateHouseholdWastePaginationQuery 오동작 수정
- validate-skills.sh에서 .cursor·.vscode 디렉터리 제외
- household-waste·k-skill-proxy 문서, 스킬, 패키지 README에 NEIS·생활쓰레기 curl 예시 정리

Made-with: Cursor
This commit is contained in:
hyeongr 2026-04-11 03:02:09 +09:00
commit 23dd424dd7
7 changed files with 215 additions and 22 deletions

View file

@ -22,12 +22,12 @@
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/household-waste/info' \
--data-urlencode 'cond[SGG_NM::LIKE]=강남구' \
--data-urlencode 'pageNo=1' \
--data-urlencode 'numOfRows=20' \
--data-urlencode 'cond[SGG_NM::LIKE]=강남구'
--data-urlencode 'numOfRows=100'
```
현재 proxy가 패스스루하는 파라미터는 `pageNo`, `numOfRows`, `cond[SGG_NM::LIKE]` 뿐이며, `returnType`은 항상 `json`으로 강제된다. 원본 API의 `cond[DAT_CRTR_YMD::*]`, `cond[DAT_UPDT_PNT::*]` 같은 부가 필터는 현재 지원하지 않는다 — 일반 사용자 질의("강남구 쓰레기 배출 요일")는 시군구 검색만으로 충분하기 때문이다.
클라이언트는 **`cond[SGG_NM::LIKE]`** 와 **`pageNo` / `numOfRows`**(또는 `page_no` / `num_of_rows`)를 **함께** 넘긴다. `pageNo` / `numOfRows` 값은 **반드시 `1` / `100`** 이어야 하고, 그 외 값이나 숫자만으로 표현되지 않는 문자열이면 proxy가 **`400`** 을 반환하고 upstream을 호출하지 않는다. upstream에는 항상 `pageNo=1`, `numOfRows=100`만 전달된다. `returnType`은 항상 `json`으로 강제된다. 원본 API의 `cond[DAT_CRTR_YMD::*]`, `cond[DAT_UPDT_PNT::*]` 같은 부가 필터는 현재 지원하지 않는다 — 일반 사용자 질의("강남구 쓰레기 배출 요일")는 시군구 검색만으로 충분하기 때문이다.
## 조회 흐름 권장 순서

View file

@ -12,13 +12,14 @@
client/skill -> k-skill-proxy -> upstream public API
```
현재 기본 엔드포인트는 아래 다섯 가지입니다.
현재 기본 엔드포인트는 아래와 같습니다.
- `GET /health`
- `GET /v1/fine-dust/report`
- `GET /v1/korea-weather/forecast`
- `GET /v1/seoul-subway/arrival`
- `GET /v1/han-river/water-level`
- `GET /v1/household-waste/info` (생활쓰레기 배출정보)
- `GET /v1/korean-stock/search`
- `GET /v1/korean-stock/base-info`
- `GET /v1/korean-stock/trade-info`
@ -155,6 +156,15 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/neis/school-meal' \
--data-urlencode 'mealDate=20260410'
```
생활쓰레기 배출정보 endpoint (`pageNo` / `numOfRows`는 반드시 `1` / `100`만 허용. 그 외 값·비정수 문자열은 `400`):
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/household-waste/info' \
--data-urlencode 'cond[SGG_NM::LIKE]=강남구' \
--data-urlencode 'pageNo=1' \
--data-urlencode 'numOfRows=100'
```
한국 주식 검색 endpoint:
```bash

View file

@ -58,13 +58,12 @@ metadata:
추가 client API 레이어는 불필요하다. Base URL은 원본 API를 기준으로 유지한다.
현재 proxy가 지원하는 쿼리 파라미터(이외 값은 무시된다):
현재 proxy가 지원하는 쿼리 파라미터:
- `serviceKey`: proxy가 서버 측에서 주입하는 인증키 (`DATA_GO_KR_API_KEY`) — 클라이언트에서 전달 금지
- `pageNo`: 페이지 번호 (기본값 `1`)
- `numOfRows`: 페이지 크기 (기본값 `20`, 최대 100)
- `returnType`: proxy가 항상 `json`으로 강제 — 클라이언트가 값을 보내도 무시된다
- `cond[SGG_NM::LIKE]`: 시군구명 포함 검색 (필수)
- `pageNo` / `numOfRows`(또는 `page_no` / `num_of_rows`): **필수**, 값은 **반드시 `1` / `100`** — 그 외 값·비정수(숫자만 아닌) 문자열은 **`400`**. upstream에는 항상 1페이지·100건만 전달한다.
- `returnType`: proxy가 항상 `json`으로 강제 — 클라이언트가 값을 보내도 무시된다
- `serviceKey`: proxy가 서버 측에서 주입 — 클라이언트에서 전달 금지
> 원본 API의 `cond[DAT_CRTR_YMD::*]`, `cond[DAT_UPDT_PNT::*]` 같은 부가 필터는 현재 proxy 라우트에서 패스스루되지 않는다. 사용자가 보내는 일반적인 질의("강남구 쓰레기 배출 요일")는 시군구 기준 검색만으로 충분하므로, 필요하다면 응답에서 `DAT_UPDT_PNT` 기준으로 클라이언트에서 정렬한다.
@ -87,9 +86,9 @@ proxy가 `serviceKey`를 서버 측에서 주입한 뒤 원본 API로 전달한
```bash
curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/household-waste/info' \
--data-urlencode "cond[SGG_NM::LIKE]=강남구" \
--data-urlencode "pageNo=1" \
--data-urlencode "numOfRows=20" \
--data-urlencode "cond[SGG_NM::LIKE]=강남구"
--data-urlencode "numOfRows=100"
```
`returnType`은 proxy가 항상 `json`으로 강제하므로 클라이언트에서 별도로 보낼 필요가 없다.

View file

@ -9,6 +9,7 @@
- `GET /v1/korea-weather/forecast`
- `GET /v1/seoul-subway/arrival`
- `GET /v1/han-river/water-level`
- `GET /v1/household-waste/info` — 생활쓰레기 배출정보(시군구)
- `GET /v1/neis/school-search` — 나이스 학교기본정보(교육청명·학교명 검색)
- `GET /v1/neis/school-meal` — 나이스 급식식단정보(일자별 메뉴)
- `GET /v1/korean-stock/search`
@ -62,6 +63,34 @@ curl -fsS --get 'http://127.0.0.1:4020/v1/han-river/water-level' \
--data-urlencode 'stationName=한강대교'
```
나이스 학교 검색·급식 식단 예시 (`KEDU_INFO_KEY` 필요). 급식은 교육청 코드(`ATPT_OFCDC_SC_CODE`)와 학교 코드(`SD_SCHUL_CODE`)가 필요하므로 보통 아래 순서로 호출한다.
학교 검색:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/neis/school-search' \
--data-urlencode 'educationOffice=서울특별시교육청' \
--data-urlencode 'schoolName=미래초등학교'
```
급식 식단:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/neis/school-meal' \
--data-urlencode 'educationOfficeCode=B10' \
--data-urlencode 'schoolCode=7010123' \
--data-urlencode 'mealDate=20260410'
```
생활쓰레기 배출정보 예시 (`DATA_GO_KR_API_KEY` 필요). `pageNo`·`numOfRows`는 반드시 `1`·`100`:
```bash
curl -fsS --get 'http://127.0.0.1:4020/v1/household-waste/info' \
--data-urlencode 'cond[SGG_NM::LIKE]=강남구' \
--data-urlencode 'pageNo=1' \
--data-urlencode 'numOfRows=100'
```
한국 주식 검색 예시:
```bash

View file

@ -16,6 +16,7 @@ const KMA_FORECAST_READY_MINUTE = 10;
const OPINET_API_BASE_URL = "https://www.opinet.co.kr/api";
const NEIS_MEAL_SERVICE_URL = "https://open.neis.go.kr/hub/mealServiceDietInfo";
const NEIS_SCHOOL_INFO_URL = "https://open.neis.go.kr/hub/schoolInfo";
const ALLOWED_AIRKOREA_ROUTES = new Map([
["MsrstnInfoInqireSvc", new Set(["getMsrstnList", "getNearbyMsrstnList", "getTMStdrCrdnt"])],
["ArpltnInforInqireSvc", new Set(["getMsrstnAcctoRltmMesureDnsty", "getCtprvnRltmMesureDnsty"])],
@ -851,6 +852,53 @@ async function proxyNeisSchoolInfoRequest({
};
}
function validateHouseholdWastePaginationQuery(query) {
const HOUSEHOLD_WASTE_PAGINATION_RULE =
"Household waste info requires pageNo=1 and numOfRows=100 (page_no and num_of_rows accepted). Other values or non-digit strings return 400.";
const rawPage = query.pageNo ?? query.page_no;
const rawNum = query.numOfRows ?? query.num_of_rows;
const pageProvided =
rawPage !== undefined && rawPage !== null && String(rawPage).trim() !== "";
const numProvided =
rawNum !== undefined && rawNum !== null && String(rawNum).trim() !== "";
if (!pageProvided || !numProvided) {
return { ok: false, message: HOUSEHOLD_WASTE_PAGINATION_RULE };
}
const parseDigitsOnlyUInt = (raw, label) => {
const s = String(raw).trim();
if (!/^\d+$/.test(s)) {
return {
ok: false,
message: `Invalid ${label} for household waste info: use digits only; pageNo must be 1 and numOfRows must be 100.`
};
}
return { ok: true, value: Number.parseInt(s, 10) };
};
const pageParsed = parseDigitsOnlyUInt(rawPage, "pageNo");
if (!pageParsed.ok) {
return pageParsed;
}
if (pageParsed.value !== 1) {
return { ok: false, message: HOUSEHOLD_WASTE_PAGINATION_RULE };
}
const numParsed = parseDigitsOnlyUInt(rawNum, "numOfRows");
if (!numParsed.ok) {
return numParsed;
}
if (numParsed.value !== 100) {
return { ok: false, message: HOUSEHOLD_WASTE_PAGINATION_RULE };
}
return { ok: true };
}
function buildServer({ env = process.env, provider = null, now = () => new Date() } = {}) {
const config = buildConfig(env);
const cache = createMemoryCache();
@ -1518,14 +1566,21 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
};
}
const pageNo = query.pageNo || "1";
const numOfRows = query.numOfRows || "20";
const paginationCheck = validateHouseholdWastePaginationQuery(query);
if (!paginationCheck.ok) {
reply.code(400);
return {
error: "bad_request",
message: paginationCheck.message
};
}
const pageNo = "1";
const numOfRows = "100";
const cacheKey = makeCacheKey({
route: "household-waste-info",
sggNm: sggNm.trim(),
pageNo,
numOfRows
sggNm: sggNm.trim()
});
const cached = cache.get(cacheKey);
if (cached) {

View file

@ -1814,13 +1814,31 @@ test("household waste info endpoint reports 503 when DATA_GO_KR_API_KEY is missi
const response = await app.inject({
method: "GET",
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC"
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=1&numOfRows=100"
});
assert.equal(response.statusCode, 503);
assert.equal(response.json().error, "upstream_not_configured");
});
test("household waste info endpoint requires pageNo and numOfRows with cond", async (t) => {
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("household waste info endpoint injects serviceKey, forces returnType=json, and caches", async (t) => {
const originalFetch = global.fetch;
const fetchCalls = [];
@ -1859,7 +1877,8 @@ test("household waste info endpoint injects serviceKey, forces returnType=json,
await app.close();
});
const url = "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=1&numOfRows=20";
const url =
"/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=1&numOfRows=100";
const first = await app.inject({ method: "GET", url });
assert.equal(first.statusCode, 200);
@ -1867,7 +1886,7 @@ test("household waste info endpoint injects serviceKey, forces returnType=json,
assert.equal(firstBody.proxy.cache.hit, false);
assert.equal(firstBody.query.sgg_nm, "강남구");
assert.equal(firstBody.query.page_no, "1");
assert.equal(firstBody.query.num_of_rows, "20");
assert.equal(firstBody.query.num_of_rows, "100");
assert.equal(firstBody.response.body.items[0].SGG_NM, "강남구");
assert.equal(fetchCalls.length, 1);
@ -1876,7 +1895,7 @@ test("household waste info endpoint injects serviceKey, forces returnType=json,
assert.equal(upstream.searchParams.get("serviceKey"), "test-key");
assert.equal(upstream.searchParams.get("returnType"), "json");
assert.equal(upstream.searchParams.get("pageNo"), "1");
assert.equal(upstream.searchParams.get("numOfRows"), "20");
assert.equal(upstream.searchParams.get("numOfRows"), "100");
assert.equal(upstream.searchParams.get("cond[SGG_NM::LIKE]"), "강남구");
const second = await app.inject({ method: "GET", url });
@ -1885,6 +1904,85 @@ test("household waste info endpoint injects serviceKey, forces returnType=json,
assert.equal(fetchCalls.length, 1);
});
test("household waste info endpoint rejects user-supplied pageNo and numOfRows when not 1 and 100", async (t) => {
const originalFetch = global.fetch;
let fetchCalls = 0;
global.fetch = async () => {
fetchCalls += 1;
return new Response(JSON.stringify({ response: { body: { items: [] } } }), {
status: 200,
headers: { "content-type": "application/json" }
});
};
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=99&numOfRows=5"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
assert.equal(fetchCalls, 0);
});
test("household waste info endpoint accepts explicit pageNo=1 and numOfRows=100", async (t) => {
const originalFetch = global.fetch;
let capturedUrl = "";
global.fetch = async (url) => {
capturedUrl = String(url);
return new Response(JSON.stringify({ response: { body: { items: [] } } }), {
status: 200,
headers: { "content-type": "application/json" }
});
};
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
global.fetch = originalFetch;
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=1&numOfRows=100"
});
assert.equal(response.statusCode, 200);
const u = new URL(capturedUrl);
assert.equal(u.searchParams.get("pageNo"), "1");
assert.equal(u.searchParams.get("numOfRows"), "100");
});
test("household waste info endpoint rejects non-integer pageNo", async (t) => {
const app = buildServer({
env: { DATA_GO_KR_API_KEY: "test-key" }
});
t.after(async () => {
await app.close();
});
const response = await app.inject({
method: "GET",
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=abc&numOfRows=100"
});
assert.equal(response.statusCode, 400);
assert.equal(response.json().error, "bad_request");
});
test("household waste info endpoint ignores user-supplied returnType override", async (t) => {
const originalFetch = global.fetch;
let capturedUrl = "";
@ -1907,7 +2005,7 @@ test("household waste info endpoint ignores user-supplied returnType override",
const response = await app.inject({
method: "GET",
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EC%88%98%EC%9B%90%EC%8B%9C&returnType=xml"
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EC%88%98%EC%9B%90%EC%8B%9C&pageNo=1&numOfRows=100&returnType=xml"
});
assert.equal(response.statusCode, 200);
@ -1929,7 +2027,7 @@ test("household waste info endpoint surfaces upstream non-200 as 502", async (t)
const response = await app.inject({
method: "GET",
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC"
url: "/v1/household-waste/info?cond%5BSGG_NM%3A%3ALIKE%5D=%EA%B0%95%EB%82%A8%EA%B5%AC&pageNo=1&numOfRows=100"
});
assert.equal(response.statusCode, 502);

View file

@ -42,6 +42,8 @@ done < <(
! -name .omx \
! -name .ouroboros \
! -name .changeset \
! -name .cursor \
! -name .vscode \
! -name docs \
! -name node_modules \
! -name packages \