mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
✨ 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:
parent
0ceff05e20
commit
23dd424dd7
7 changed files with 215 additions and 22 deletions
|
|
@ -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::*]` 같은 부가 필터는 현재 지원하지 않는다 — 일반 사용자 질의("강남구 쓰레기 배출 요일")는 시군구 검색만으로 충분하기 때문이다.
|
||||
|
||||
## 조회 흐름 권장 순서
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`으로 강제하므로 클라이언트에서 별도로 보낼 필요가 없다.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ done < <(
|
|||
! -name .omx \
|
||||
! -name .ouroboros \
|
||||
! -name .changeset \
|
||||
! -name .cursor \
|
||||
! -name .vscode \
|
||||
! -name docs \
|
||||
! -name node_modules \
|
||||
! -name packages \
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue