mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Fix parking lot lookups: force HTTPS, cache full dataset, normalize provider fields (#156)
Data.go.kr 이 tn_pubr_prkplce_info_api 를 HTTPS 로만 서비스하고 HTTP 요청은 301 로 리다이렉트하기 때문에 Node fetch 가 `response.ok=false` 로 떨어져 기능이 전체 실패하고 있었다. 이 커밋은 HTTPS 로 직접 호출하도록 수정하면서, 업스트림의 주소/지역 필터가 실제로는 동작하지 않고 페이지당 응답이 1000rows 기준 26s 에 달해 20s fetch timeout 에 꾸준히 걸리던 문제까지 함께 해결한다. ## What changed - packages/k-skill-proxy/src/parking-lots.js - PARKING_LOT_API_URL 을 `http://` → `https://` 로 고정 (root cause). - 업스트림 address/geo 필터가 신뢰 불가하므로 full-dataset 을 한 번 로드해 프로세스 메모리에 6시간 TTL 로 캐시하고, 동시 호출자는 in-flight promise 를 공유하도록 한다. nearby 쿼리는 캐시된 행을 좌표 거리로 필터링해 서비스한다. - DATASET_PAGE_SIZE=300, fetch timeout 30s 로 페이지당 응답이 20s 를 넘기지 않도록 맞췄다. - packages/k-skill-proxy/src/server.js - 더 이상 의미 없어진 numOfRows / maxPages 쿼리 파라미터를 라우트에서 제거하고, 응답 payload 의 query echo 도 정리했다. - packages/k-skill-proxy/test/server.test.js - 새 캐시 기반 동작을 검증하는 테스트로 교체: (1) full dataset load + 좌표 필터 + 프록시 응답 캐시 재사용, (2) public_only 기본값 및 해제 시 동작, (3) 좌표 검증 실패 400, (4) 업스트림 키 미설정 시 503. - packages/parking-lot-search/src/index.js - OFFICIAL_API_URL 도 HTTPS 로 맞춰 직접 호출 모드 사용자도 같은 버그를 밟지 않게 한다. - packages/parking-lot-search/src/parse.js - 업스트림이 `insttCode` / `insttNm` (camelCase) 를 돌려주는데 parser 가 snake_case (`instt_code`, `instt_nm`) 만 인식해 providerCode/providerName 이 비어 있던 문제를 수정. - packages/parking-lot-search/test/* 및 fixtures - HTTPS URL 매칭으로 업데이트하고, insttCode/insttNm 회귀 테스트를 fixture/assertion 에 추가. - docs/features/parking-lot-search.md, parking-lot-search/SKILL.md, packages/parking-lot-search/README.md - 공식 endpoint 표기를 HTTPS 로 통일. - .changeset/parking-lot-https-fix.md - parking-lot-search 패키지 patch 릴리즈 노트 추가. ## How it was verified - `npm run ci` (lint + typecheck + tests + pack:dry-run) 통과. - 로컬에서 실제 `DATA_GO_KR_API_KEY` 로 k-skill-proxy 를 기동해 live 호출 검증: - 광화문 (37.573713, 126.978338) cold cache: 30s 내 전체 18,868 rows 로드, 2km 내 47개 공영주차장 반환 (세종로 414m, 서린노외 456m 등). - 강남역 (37.497952, 127.027621) warm cache: 31ms 응답, 1.5km 내 13개 반환 (역삼문화공원 380m, 역삼푸른솔도서관 421m 등). - 업스트림 직접 HTTPS 호출로 `resultCode=00 NORMAL_SERVICE` 정상 동작 확인.
This commit is contained in:
parent
f94b049613
commit
e1656541a4
11 changed files with 273 additions and 115 deletions
8
.changeset/parking-lot-https-fix.md
Normal file
8
.changeset/parking-lot-https-fix.md
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
"parking-lot-search": patch
|
||||
---
|
||||
|
||||
Fix parking lot lookups after Data.go.kr enforced HTTPS on `api.data.go.kr`.
|
||||
|
||||
- Switch the official API URL from `http://` to `https://` so callers that use `buildOfficialParkingLotApiUrl` / direct-API mode no longer hit the HTTP → HTTPS 301 redirect that broke Node `fetch` based clients.
|
||||
- Recognize the upstream's camelCase `insttCode` / `insttNm` provider fields in addition to the previously-handled snake_case variants so `providerCode` / `providerName` stay populated.
|
||||
|
|
@ -60,4 +60,4 @@ Issue #135에서 모두의주차장 연동 가능성이 언급되었지만 v1은
|
|||
|
||||
- 표준데이터: <https://www.data.go.kr/data/15012896/standard.do>
|
||||
- Open API: <https://www.data.go.kr/data/15012896/openapi.do>
|
||||
- Endpoint: `http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api`
|
||||
- Endpoint: `https://api.data.go.kr/openapi/tn_pubr_prkplce_info_api`
|
||||
|
|
|
|||
|
|
@ -1,6 +1,26 @@
|
|||
const { normalizeParkingLotRows } = require("../../parking-lot-search/src/parse");
|
||||
|
||||
const PARKING_LOT_API_URL = "http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api";
|
||||
// Data.go.kr now serves this endpoint over HTTPS and redirects HTTP requests
|
||||
// with a 301 Moved Permanently, which causes Node `fetch` to surface a
|
||||
// non-OK response even though the actual service is healthy. Use HTTPS
|
||||
// directly so the proxy never depends on cross-protocol redirect handling.
|
||||
const PARKING_LOT_API_URL = "https://api.data.go.kr/openapi/tn_pubr_prkplce_info_api";
|
||||
|
||||
// Upstream does not support working address-based or geographic filters, and
|
||||
// per-page latency is proportional to row count. We therefore cache the
|
||||
// entire dataset in-process and serve nearby lookups by coordinate-distance
|
||||
// filtering over the cached rows. This keeps most requests near-instant,
|
||||
// while the periodic background refresh pays the full-dataset latency once
|
||||
// per TTL window regardless of how many clients are calling.
|
||||
const DATASET_PAGE_SIZE = 300;
|
||||
const DATASET_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
const DATASET_FETCH_TIMEOUT_MS = 45000;
|
||||
|
||||
const datasetCache = {
|
||||
rows: null,
|
||||
fetchedAt: 0,
|
||||
loadPromise: null
|
||||
};
|
||||
|
||||
function parseInteger(value, fallback) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
|
|
@ -10,7 +30,7 @@ function parseInteger(value, fallback) {
|
|||
function buildParkingLotApiUrl({
|
||||
serviceKey,
|
||||
pageNo = 1,
|
||||
numOfRows = 1000,
|
||||
numOfRows = DATASET_PAGE_SIZE,
|
||||
addressHint = null,
|
||||
addressField = "rdnmadr",
|
||||
publicOnly = true,
|
||||
|
|
@ -39,7 +59,7 @@ function buildParkingLotApiUrl({
|
|||
async function fetchParkingLotPage({
|
||||
serviceKey,
|
||||
pageNo = 1,
|
||||
numOfRows = 1000,
|
||||
numOfRows = DATASET_PAGE_SIZE,
|
||||
addressHint = null,
|
||||
addressField = "rdnmadr",
|
||||
publicOnly = true,
|
||||
|
|
@ -56,7 +76,7 @@ async function fetchParkingLotPage({
|
|||
parkingType
|
||||
});
|
||||
const response = await fetchImpl(url, {
|
||||
signal: AbortSignal.timeout(20000)
|
||||
signal: AbortSignal.timeout(DATASET_FETCH_TIMEOUT_MS)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -95,21 +115,56 @@ function getItems(payload) {
|
|||
return [];
|
||||
}
|
||||
|
||||
function mergeParkingLotPayloads(payloads) {
|
||||
const first = payloads[0] || { response: { header: { resultCode: "00", resultMsg: "NORMAL_SERVICE" }, body: {} } };
|
||||
const body = getBody(first);
|
||||
return {
|
||||
response: {
|
||||
header: first.response?.header || { resultCode: "00", resultMsg: "NORMAL_SERVICE" },
|
||||
body: {
|
||||
...body,
|
||||
items: payloads.flatMap((payload) => getItems(payload)),
|
||||
async function loadFullDataset({ serviceKey, fetchImpl, publicOnly = false }) {
|
||||
const now = Date.now();
|
||||
if (datasetCache.rows && now - datasetCache.fetchedAt < DATASET_CACHE_TTL_MS) {
|
||||
return datasetCache.rows;
|
||||
}
|
||||
if (datasetCache.loadPromise) {
|
||||
return datasetCache.loadPromise;
|
||||
}
|
||||
|
||||
datasetCache.loadPromise = (async () => {
|
||||
try {
|
||||
const firstPage = await fetchParkingLotPage({
|
||||
serviceKey,
|
||||
pageNo: 1,
|
||||
numOfRows: payloads.reduce((sum, payload) => sum + getItems(payload).length, 0),
|
||||
totalCount: body.totalCount ?? payloads.reduce((sum, payload) => sum + getItems(payload).length, 0)
|
||||
numOfRows: DATASET_PAGE_SIZE,
|
||||
publicOnly,
|
||||
fetchImpl
|
||||
});
|
||||
const totalCountRaw = getBody(firstPage).totalCount;
|
||||
const totalCount = parseInteger(totalCountRaw, getItems(firstPage).length);
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / DATASET_PAGE_SIZE));
|
||||
|
||||
let remainingPages = [];
|
||||
if (totalPages > 1) {
|
||||
const pageNumbers = Array.from({ length: totalPages - 1 }, (_, index) => index + 2);
|
||||
remainingPages = await Promise.all(pageNumbers.map((pageNo) => fetchParkingLotPage({
|
||||
serviceKey,
|
||||
pageNo,
|
||||
numOfRows: DATASET_PAGE_SIZE,
|
||||
publicOnly,
|
||||
fetchImpl
|
||||
})));
|
||||
}
|
||||
|
||||
const rows = [firstPage, ...remainingPages].flatMap((payload) => getItems(payload));
|
||||
datasetCache.rows = rows;
|
||||
datasetCache.fetchedAt = Date.now();
|
||||
return rows;
|
||||
} finally {
|
||||
datasetCache.loadPromise = null;
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
return datasetCache.loadPromise;
|
||||
}
|
||||
|
||||
function resetParkingDatasetCacheForTests() {
|
||||
datasetCache.rows = null;
|
||||
datasetCache.fetchedAt = 0;
|
||||
datasetCache.loadPromise = null;
|
||||
}
|
||||
|
||||
async function fetchNearbyParkingLots({
|
||||
|
|
@ -121,8 +176,6 @@ async function fetchNearbyParkingLots({
|
|||
addressHint = null,
|
||||
publicOnly = true,
|
||||
parkingType = null,
|
||||
numOfRows = 1000,
|
||||
maxPages = 1,
|
||||
fetchImpl = global.fetch
|
||||
}) {
|
||||
if (!serviceKey) {
|
||||
|
|
@ -132,22 +185,41 @@ async function fetchNearbyParkingLots({
|
|||
};
|
||||
}
|
||||
|
||||
const pageCount = Math.max(1, Math.min(10, parseInteger(maxPages, 1)));
|
||||
const payloads = [];
|
||||
for (let pageNo = 1; pageNo <= pageCount; pageNo += 1) {
|
||||
payloads.push(await fetchParkingLotPage({
|
||||
serviceKey,
|
||||
pageNo,
|
||||
numOfRows,
|
||||
addressHint,
|
||||
publicOnly,
|
||||
parkingType,
|
||||
fetchImpl
|
||||
}));
|
||||
}
|
||||
const cacheAgeBefore = datasetCache.rows ? Date.now() - datasetCache.fetchedAt : null;
|
||||
const cacheHit = datasetCache.rows !== null && cacheAgeBefore !== null && cacheAgeBefore < DATASET_CACHE_TTL_MS;
|
||||
|
||||
const mergedPayload = mergeParkingLotPayloads(payloads);
|
||||
const allItems = normalizeParkingLotRows(mergedPayload, { latitude, longitude }, { radius, publicOnly });
|
||||
const rows = await loadFullDataset({ serviceKey, fetchImpl, publicOnly: false });
|
||||
|
||||
const mergedPayload = {
|
||||
response: {
|
||||
header: { resultCode: "00", resultMsg: "NORMAL_SERVICE" },
|
||||
body: {
|
||||
items: rows,
|
||||
pageNo: 1,
|
||||
numOfRows: rows.length,
|
||||
totalCount: rows.length
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const typeFiltered = parkingType
|
||||
? rows.filter((row) => String(row.prkplceType ?? row["주차장유형"] ?? "").trim() === String(parkingType).trim())
|
||||
: rows;
|
||||
const filteredPayload = parkingType
|
||||
? {
|
||||
response: {
|
||||
header: mergedPayload.response.header,
|
||||
body: {
|
||||
...mergedPayload.response.body,
|
||||
items: typeFiltered,
|
||||
numOfRows: typeFiltered.length,
|
||||
totalCount: typeFiltered.length
|
||||
}
|
||||
}
|
||||
}
|
||||
: mergedPayload;
|
||||
|
||||
const allItems = normalizeParkingLotRows(filteredPayload, { latitude, longitude }, { radius, publicOnly });
|
||||
|
||||
return {
|
||||
anchor: {
|
||||
|
|
@ -163,21 +235,25 @@ async function fetchNearbyParkingLots({
|
|||
radius,
|
||||
publicOnly,
|
||||
addressHint,
|
||||
numOfRows,
|
||||
maxPages: pageCount,
|
||||
source: "data.go.kr"
|
||||
source: "data.go.kr",
|
||||
datasetCacheHit: cacheHit,
|
||||
datasetSize: rows.length,
|
||||
datasetFetchedAt: new Date(datasetCache.fetchedAt).toISOString()
|
||||
},
|
||||
upstream: {
|
||||
endpoint: PARKING_LOT_API_URL,
|
||||
pages: pageCount,
|
||||
total_count: getBody(payloads[0] || {}).totalCount ?? null
|
||||
total_count: rows.length
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PARKING_LOT_API_URL,
|
||||
DATASET_PAGE_SIZE,
|
||||
DATASET_CACHE_TTL_MS,
|
||||
buildParkingLotApiUrl,
|
||||
fetchNearbyParkingLots,
|
||||
fetchParkingLotPage
|
||||
fetchParkingLotPage,
|
||||
loadFullDataset,
|
||||
resetParkingDatasetCacheForTests
|
||||
};
|
||||
|
|
|
|||
|
|
@ -728,16 +728,6 @@ function normalizeParkingLotSearchQuery(query) {
|
|||
throw new Error("radius must be between 1 and 50000.");
|
||||
}
|
||||
|
||||
const numOfRows = parseInteger(query.numOfRows ?? query.num_of_rows, 1000);
|
||||
if (numOfRows < 1 || numOfRows > 1000) {
|
||||
throw new Error("numOfRows must be between 1 and 1000.");
|
||||
}
|
||||
|
||||
const maxPages = parseInteger(query.maxPages ?? query.max_pages, 1);
|
||||
if (maxPages < 1 || maxPages > 10) {
|
||||
throw new Error("maxPages must be between 1 and 10.");
|
||||
}
|
||||
|
||||
const publicOnlyRaw = trimOrNull(query.publicOnly ?? query.public_only);
|
||||
const publicOnly = publicOnlyRaw
|
||||
? !["0", "false", "n", "no"].includes(publicOnlyRaw.toLowerCase())
|
||||
|
|
@ -750,8 +740,6 @@ function normalizeParkingLotSearchQuery(query) {
|
|||
longitude,
|
||||
limit,
|
||||
radius,
|
||||
numOfRows,
|
||||
maxPages,
|
||||
publicOnly,
|
||||
addressHint,
|
||||
parkingType
|
||||
|
|
@ -1983,9 +1971,7 @@ function buildServer({ env = process.env, provider = null, now = () => new Date(
|
|||
radius: normalized.radius,
|
||||
public_only: normalized.publicOnly,
|
||||
address_hint: normalized.addressHint,
|
||||
parking_type: normalized.parkingType,
|
||||
num_of_rows: normalized.numOfRows,
|
||||
max_pages: normalized.maxPages
|
||||
parking_type: normalized.parkingType
|
||||
},
|
||||
proxy: {
|
||||
name: config.proxyName,
|
||||
|
|
|
|||
|
|
@ -3474,64 +3474,75 @@ test("data4library book-exists endpoint requires library code and isbn13 then pr
|
|||
});
|
||||
});
|
||||
|
||||
test("parking lot search endpoint normalizes, caches, and keeps the proxy public", async (t) => {
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
global.fetch = async (url) => {
|
||||
function buildParkingDatasetFetchMock({ fetchCalls, totalCount, seoulRow, otherRows = [] }) {
|
||||
return async (url) => {
|
||||
const resolved = String(url);
|
||||
fetchCalls.push(resolved);
|
||||
assert.match(resolved, /^http:\/\/api\.data\.go\.kr\/openapi\/tn_pubr_prkplce_info_api\?/);
|
||||
const urlObject = new URL(resolved);
|
||||
assert.match(resolved, /^https:\/\/api\.data\.go\.kr\/openapi\/tn_pubr_prkplce_info_api\?/);
|
||||
assert.equal(urlObject.searchParams.get("serviceKey"), "data-key");
|
||||
assert.equal(urlObject.searchParams.get("type"), "json");
|
||||
assert.equal(urlObject.searchParams.get("prkplceSe"), "공영");
|
||||
assert.equal(urlObject.searchParams.get("rdnmadr"), "서울특별시 종로구");
|
||||
assert.ok(!urlObject.searchParams.has("rdnmadr"));
|
||||
assert.ok(!urlObject.searchParams.has("lnmadr"));
|
||||
assert.ok(!urlObject.searchParams.has("prkplceSe"));
|
||||
|
||||
const pageNo = Number(urlObject.searchParams.get("pageNo") || 1);
|
||||
const items = pageNo === 1 ? [seoulRow, ...otherRows] : [];
|
||||
return new Response(JSON.stringify({
|
||||
response: {
|
||||
header: { resultCode: "00", resultMsg: "NORMAL_SERVICE" },
|
||||
body: {
|
||||
pageNo: 1,
|
||||
numOfRows: 1000,
|
||||
totalCount: 2,
|
||||
items: [
|
||||
{
|
||||
prkplceNo: "111-2-000001",
|
||||
prkplceNm: "종로구청 공영주차장",
|
||||
prkplceSe: "공영",
|
||||
prkplceType: "노외",
|
||||
rdnmadr: "서울특별시 종로구 삼봉로 43",
|
||||
prkcmprt: "50",
|
||||
parkingchrgeInfo: "유료",
|
||||
basicTime: "30",
|
||||
basicCharge: "1000",
|
||||
institutionNm: "서울특별시 종로구청",
|
||||
latitude: "37.57320",
|
||||
longitude: "126.97810",
|
||||
pwdbsPpkZoneYn: "Y",
|
||||
referenceDate: "2026-03-01"
|
||||
},
|
||||
{
|
||||
prkplceNo: "111-2-000003",
|
||||
prkplceNm: "광화문광장 공영주차장",
|
||||
prkplceSe: "공영",
|
||||
prkplceType: "노상",
|
||||
rdnmadr: "서울특별시 종로구 세종대로 172",
|
||||
prkcmprt: "20",
|
||||
parkingchrgeInfo: "무료",
|
||||
latitude: "37.57375",
|
||||
longitude: "126.97836",
|
||||
pwdbsPpkZoneYn: "N",
|
||||
referenceDate: "2026-03-01"
|
||||
}
|
||||
]
|
||||
pageNo,
|
||||
numOfRows: items.length,
|
||||
totalCount,
|
||||
items
|
||||
}
|
||||
}
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json;charset=UTF-8" }
|
||||
});
|
||||
}), { status: 200, headers: { "content-type": "application/json" } });
|
||||
};
|
||||
}
|
||||
|
||||
test("parking lot search endpoint caches the full dataset and serves nearby queries from memory", async (t) => {
|
||||
const { resetParkingDatasetCacheForTests } = require("../src/parking-lots");
|
||||
resetParkingDatasetCacheForTests();
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
const seoulRow = {
|
||||
prkplceNo: "111-2-000003",
|
||||
prkplceNm: "광화문광장 공영주차장",
|
||||
prkplceSe: "공영",
|
||||
prkplceType: "노상",
|
||||
rdnmadr: "서울특별시 종로구 세종대로 172",
|
||||
prkcmprt: "20",
|
||||
parkingchrgeInfo: "무료",
|
||||
institutionNm: "서울특별시 종로구청",
|
||||
latitude: "37.57375",
|
||||
longitude: "126.97836",
|
||||
pwdbsPpkZoneYn: "N",
|
||||
referenceDate: "2026-03-01",
|
||||
insttCode: "3000000",
|
||||
insttNm: "서울특별시 종로구"
|
||||
};
|
||||
const jejuRow = {
|
||||
prkplceNo: "500-2-000001",
|
||||
prkplceNm: "제주공영주차장",
|
||||
prkplceSe: "공영",
|
||||
prkplceType: "노외",
|
||||
rdnmadr: "제주특별자치도 제주시 연동 1",
|
||||
prkcmprt: "100",
|
||||
parkingchrgeInfo: "무료",
|
||||
latitude: "33.4761",
|
||||
longitude: "126.5472",
|
||||
pwdbsPpkZoneYn: "N",
|
||||
referenceDate: "2026-03-01"
|
||||
};
|
||||
global.fetch = buildParkingDatasetFetchMock({
|
||||
fetchCalls,
|
||||
totalCount: 2,
|
||||
seoulRow,
|
||||
otherRows: [jejuRow]
|
||||
});
|
||||
|
||||
const app = buildServer({
|
||||
env: {
|
||||
|
|
@ -3542,29 +3553,101 @@ test("parking lot search endpoint normalizes, caches, and keeps the proxy public
|
|||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
resetParkingDatasetCacheForTests();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const first = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/parking-lots/search?latitude=37.57371315593711&longitude=126.97833785777944&address_hint=%EC%84%9C%EC%9A%B8%ED%8A%B9%EB%B3%84%EC%8B%9C%20%EC%A2%85%EB%A1%9C%EA%B5%AC&limit=2&radius=1000"
|
||||
url: "/v1/parking-lots/search?latitude=37.57371315593711&longitude=126.97833785777944&limit=2&radius=1000"
|
||||
});
|
||||
const second = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/parking-lots/search?lat=37.57371315593711&lon=126.97833785777944&addressHint=%EC%84%9C%EC%9A%B8%ED%8A%B9%EB%B3%84%EC%8B%9C%20%EC%A2%85%EB%A1%9C%EA%B5%AC&limit=2&radius=1000"
|
||||
url: "/v1/parking-lots/search?lat=37.57371315593711&lon=126.97833785777944&limit=2&radius=1000"
|
||||
});
|
||||
const third = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/parking-lots/search?latitude=37.58&longitude=126.98&limit=2&radius=2000"
|
||||
});
|
||||
|
||||
assert.equal(first.statusCode, 200);
|
||||
assert.equal(second.statusCode, 200);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(third.statusCode, 200);
|
||||
assert.equal(first.json().items.length, 1);
|
||||
assert.equal(first.json().items[0].name, "광화문광장 공영주차장");
|
||||
assert.equal(first.json().items[0].feeInfo, "무료");
|
||||
assert.equal(first.json().items[1].basicCharge, 1000);
|
||||
assert.equal(first.json().query.address_hint, "서울특별시 종로구");
|
||||
assert.equal(first.json().items[0].providerName, "서울특별시 종로구");
|
||||
assert.equal(first.json().meta.datasetCacheHit, false);
|
||||
assert.equal(third.json().meta.datasetCacheHit, true);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(first.json().proxy.cache.hit, false);
|
||||
assert.equal(second.json().proxy.cache.hit, true);
|
||||
});
|
||||
|
||||
test("parking lot search endpoint filters cached dataset by radius and excludes private lots by default", async (t) => {
|
||||
const { resetParkingDatasetCacheForTests } = require("../src/parking-lots");
|
||||
resetParkingDatasetCacheForTests();
|
||||
|
||||
const originalFetch = global.fetch;
|
||||
const fetchCalls = [];
|
||||
const privateSeoulRow = {
|
||||
prkplceNo: "111-2-000002",
|
||||
prkplceNm: "세종로 민영주차장",
|
||||
prkplceSe: "민영",
|
||||
prkplceType: "노외",
|
||||
rdnmadr: "서울특별시 종로구 세종대로 170",
|
||||
prkcmprt: "100",
|
||||
parkingchrgeInfo: "유료",
|
||||
latitude: "37.573714",
|
||||
longitude: "126.978339",
|
||||
referenceDate: "2026-03-01"
|
||||
};
|
||||
const publicSeoulRow = {
|
||||
prkplceNo: "111-2-000003",
|
||||
prkplceNm: "광화문광장 공영주차장",
|
||||
prkplceSe: "공영",
|
||||
prkplceType: "노상",
|
||||
rdnmadr: "서울특별시 종로구 세종대로 172",
|
||||
prkcmprt: "20",
|
||||
parkingchrgeInfo: "무료",
|
||||
latitude: "37.57375",
|
||||
longitude: "126.97836",
|
||||
pwdbsPpkZoneYn: "N",
|
||||
referenceDate: "2026-03-01"
|
||||
};
|
||||
global.fetch = buildParkingDatasetFetchMock({
|
||||
fetchCalls,
|
||||
totalCount: 2,
|
||||
seoulRow: publicSeoulRow,
|
||||
otherRows: [privateSeoulRow]
|
||||
});
|
||||
|
||||
const app = buildServer({ env: { DATA_GO_KR_API_KEY: "data-key" } });
|
||||
|
||||
t.after(async () => {
|
||||
global.fetch = originalFetch;
|
||||
resetParkingDatasetCacheForTests();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
const publicOnly = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/parking-lots/search?latitude=37.57375&longitude=126.97836&limit=5&radius=500"
|
||||
});
|
||||
const includePrivate = await app.inject({
|
||||
method: "GET",
|
||||
url: "/v1/parking-lots/search?latitude=37.57375&longitude=126.97836&limit=5&radius=500&public_only=false"
|
||||
});
|
||||
|
||||
assert.equal(publicOnly.statusCode, 200);
|
||||
assert.equal(includePrivate.statusCode, 200);
|
||||
assert.equal(publicOnly.json().items.length, 1);
|
||||
assert.equal(publicOnly.json().items[0].name, "광화문광장 공영주차장");
|
||||
const includedCategories = includePrivate.json().items.map((item) => item.category);
|
||||
assert.equal(includePrivate.json().items.length, 2);
|
||||
assert.ok(includedCategories.includes("공영"));
|
||||
assert.ok(includedCategories.includes("민영"));
|
||||
});
|
||||
|
||||
test("parking lot search endpoint validates coordinates before upstream calls", async (t) => {
|
||||
const app = buildServer({
|
||||
env: {
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ Korean parking-lot lookup backed first by the official Data.go.kr `전국주차
|
|||
|
||||
- Data.go.kr standard dataset: `https://www.data.go.kr/data/15012896/standard.do`
|
||||
- Data.go.kr Open API docs: `https://www.data.go.kr/data/15012896/openapi.do`
|
||||
- Open API endpoint: `http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api`
|
||||
- Open API endpoint: `https://api.data.go.kr/openapi/tn_pubr_prkplce_info_api`
|
||||
- Kakao Map mobile search: `https://m.map.kakao.com/actions/searchView?q=<query>`
|
||||
- Kakao Map place panel JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ const {
|
|||
|
||||
const SEARCH_VIEW_URL = "https://m.map.kakao.com/actions/searchView";
|
||||
const PLACE_PANEL_URL_BASE = "https://place-api.map.kakao.com/places/panel3";
|
||||
const OFFICIAL_API_URL = "http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api";
|
||||
const OFFICIAL_API_URL = "https://api.data.go.kr/openapi/tn_pubr_prkplce_info_api";
|
||||
const DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org";
|
||||
const DEFAULT_BROWSER_HEADERS = {
|
||||
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
|
|
|
|||
|
|
@ -288,8 +288,8 @@ function normalizeParkingLotRows(payload, origin, options = {}) {
|
|||
phone: String(row.phoneNumber ?? row["전화번호"] ?? "").trim() || null,
|
||||
hasAccessibleParking: toBooleanYesNo(row.pwdbsPpkZoneYn ?? row["장애인전용주차구역보유여부"]),
|
||||
referenceDate: String(row.referenceDate ?? row["데이터기준일자"] ?? "").trim() || null,
|
||||
providerCode: String(row.instt_code ?? row["제공기관코드"] ?? "").trim() || null,
|
||||
providerName: String(row.instt_nm ?? row["제공기관기관명"] ?? row["제공기관명"] ?? "").trim() || null,
|
||||
providerCode: String(row.insttCode ?? row.instt_code ?? row["제공기관코드"] ?? "").trim() || null,
|
||||
providerName: String(row.insttNm ?? row.instt_nm ?? row["제공기관기관명"] ?? row["제공기관명"] ?? "").trim() || null,
|
||||
mapUrl: buildMapUrl(name, itemLatitude, itemLongitude)
|
||||
};
|
||||
})
|
||||
|
|
|
|||
|
|
@ -76,7 +76,8 @@
|
|||
"longitude": "126.97836",
|
||||
"pwdbsPpkZoneYn": "N",
|
||||
"referenceDate": "2026-03-01",
|
||||
"instt_nm": "서울특별시 종로구"
|
||||
"insttCode": "3000000",
|
||||
"insttNm": "서울특별시 종로구"
|
||||
},
|
||||
{
|
||||
"prkplceNo": "111-2-000004",
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ test("buildOfficialParkingLotApiUrl targets the standard public parking API with
|
|||
publicOnly: true
|
||||
}));
|
||||
|
||||
assert.equal(url.origin + url.pathname, "http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api");
|
||||
assert.equal(url.origin + url.pathname, "https://api.data.go.kr/openapi/tn_pubr_prkplce_info_api");
|
||||
assert.equal(url.searchParams.get("serviceKey"), "data-key");
|
||||
assert.equal(url.searchParams.get("type"), "json");
|
||||
assert.equal(url.searchParams.get("pageNo"), "2");
|
||||
|
|
@ -65,6 +65,10 @@ test("normalizeParkingLotRows keeps public parking metadata and sorts by distanc
|
|||
assert.equal(items[0].hasAccessibleParking, false);
|
||||
assert.equal(items[1].basicCharge, 1000);
|
||||
assert.equal(items[1].hasAccessibleParking, true);
|
||||
assert.equal(items[0].providerCode, "3000000");
|
||||
assert.equal(items[0].providerName, "서울특별시 종로구");
|
||||
assert.equal(items[1].providerCode, "3000000");
|
||||
assert.equal(items[1].providerName, "서울특별시 종로구");
|
||||
assert.equal(
|
||||
items[0].mapUrl,
|
||||
"https://map.kakao.com/link/map/%EA%B4%91%ED%99%94%EB%AC%B8%EA%B4%91%EC%9E%A5%20%EA%B3%B5%EC%98%81%EC%A3%BC%EC%B0%A8%EC%9E%A5,37.57375,126.97836"
|
||||
|
|
@ -99,7 +103,7 @@ test("searchNearbyParkingLotsByCoordinates uses the official API directly when a
|
|||
assert.equal(result.items[0].name, "광화문광장 공영주차장");
|
||||
assert.equal(result.meta.total, 2);
|
||||
assert.equal(result.meta.source, "data.go.kr");
|
||||
assert.match(calls[0], /^http:\/\/api\.data\.go\.kr\/openapi\/tn_pubr_prkplce_info_api\?/);
|
||||
assert.match(calls[0], /^https:\/\/api\.data\.go\.kr\/openapi\/tn_pubr_prkplce_info_api\?/);
|
||||
assert.match(calls[0], /rdnmadr=%EC%84%9C%EC%9A%B8%ED%8A%B9%EB%B3%84%EC%8B%9C\+%EC%A2%85%EB%A1%9C%EA%B5%AC/);
|
||||
});
|
||||
|
||||
|
|
@ -145,7 +149,7 @@ test("searchNearbyParkingLotsByLocationQuery resolves a Kakao anchor and derives
|
|||
return makeResponse(anchorPanel, "application/json");
|
||||
}
|
||||
|
||||
if (resolved.startsWith("http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api?")) {
|
||||
if (resolved.startsWith("https://api.data.go.kr/openapi/tn_pubr_prkplce_info_api?")) {
|
||||
const urlObject = new URL(resolved);
|
||||
assert.equal(urlObject.searchParams.get("rdnmadr"), "서울특별시 종로구");
|
||||
return makeResponse(parkingApiResponse);
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ metadata:
|
|||
|
||||
- 표준데이터 안내: `https://www.data.go.kr/data/15012896/standard.do`
|
||||
- Open API 안내: `https://www.data.go.kr/data/15012896/openapi.do`
|
||||
- Open API endpoint: `http://api.data.go.kr/openapi/tn_pubr_prkplce_info_api`
|
||||
- Open API endpoint: `https://api.data.go.kr/openapi/tn_pubr_prkplce_info_api`
|
||||
- k-skill proxy: `/v1/parking-lots/search`
|
||||
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView?q=<query>`
|
||||
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue