Skip to content

fix(responses): sync query API schemas and add runtime response validation#154

Merged
Palbahngmiyine merged 10 commits intosolapi:betafrom
Palbahngmiyine:sync/response-schema-validation-beta
Apr 17, 2026
Merged

fix(responses): sync query API schemas and add runtime response validation#154
Palbahngmiyine merged 10 commits intosolapi:betafrom
Palbahngmiyine:sync/response-schema-validation-beta

Conversation

@Palbahngmiyine
Copy link
Copy Markdown
Member

Summary

  • SOLAPI 서버 응답 드리프트(GET /cash/v1/balance 확장 포함)를 실제 호출로 확인하고 15개 조회 API의 Effect Schema를 정합화
  • 발송 요청용 messageSchema와 분리된 조회 응답 전용 storedMessageSchema 신설
  • DefaultService.requestEffect/getWithQuery에 optional responseSchema 주입 경로 마련, decodeServerResponse 헬퍼로 스키마 불일치 시 전용 ResponseSchemaMismatchError 발생 (production PII redact 포함)

주요 수정 (응답 스키마)

API 변경
getBalance lowBalanceAlert, minimumCash, rechargeTo, rechargeTryCount, autoRecharge, accountId, deposit, balanceOnly 9개 필드 추가
getStatistics deposit/monthlyDepositAvg, BMS·fax·voice·rcs_itpl/ltpl 메시지 타입, statusCode sparse 허용 (partialMessageTypeRecordSchema)
getMessages / getGroupMessages storedMessageSchema 사용, nextKey/startKey optional, boolean 필드 0/1 수용 + 정규화
getGroups / getGroup scheduledDate/dateSent/dateCompleted, sdkVersion/osPlatform nullable
getBlacks blackList Record → Array
getBlockGroups name nullable
getKakaoChannel phoneNumber optional
getKakaoAlimtalkTemplate(s) accountId nullable, list startKey NullishOr

런타임 검증 도입

  • DefaultService.requestEffect / getWithQueryresponseSchema?: Schema.Schema<R, I, never> 주입 경로
  • decodeServerResponse 헬퍼: Schema.decodeUnknownonExcessProperty: 'preserve' 옵션 적용 — 서버가 내려준 미선언 필드가 strip 되어 소비자 응답에서 조용히 사라지는 silent data loss 방지
  • 신규 ResponseSchemaMismatchError: 2xx 응답의 스키마 불일치를 5xx ServerError와 분리 (재시도/알림 분기 오염 방지)
  • validationErrors(ArrayFormatter) 및 responseBody 보존으로 운영 환경 재현 가능
  • shouldRedactSensitive() safe-by-default gate: NODE_ENV.trim().toLowerCase() 정규화 후 development/test 외 환경(production, staging, undefined 등)에서는 PII(message/validationErrors/url/responseBody)를 redact
  • URL redact: new URL() 기반으로 query + fragment + userinfo(user:password@)까지 마스킹
  • booleanOrZeroOne transform: 서버의 boolean|0|1 혼재를 boolean으로 정규화, 0/1 외의 숫자(NaN, 2, -1 등)는 drift 신호로 취급해 fail-loud

⚠️ Breaking Changes (v6 beta 라인)

기존 타입이 실제 서버 응답과 달라 as R 캐스팅으로 숨겨지던 타입 거짓말을 바로잡는 과정에서 public surface가 좁혀짐:

  • GetMessagesResponse.messageList[id] / GroupMessages 아이템 타입이 MessageSchemaStoredMessage로 변경. kakaoOptions 등 중첩 옵션은 서버 정규화 포맷(발송 요청과 다름)이어서 Record<string, unknown>으로 선언. 기존 nested 필드 접근은 별도 상세 스키마가 정의되기 전까지는 캐스팅 또는 런타임 파싱이 필요.
  • GetBlacksResponse.blackList 타입이 Record<string, Black>Array<Black>로 변경 (실제 서버 응답 형태).
  • GetKakaoChannelsFinalizeResponse.startKey / nextKey 타입이 string | null | undefined로 확장.
  • KakaoChannel.phoneNumber가 optional.
  • groupSchema.sdkVersion/osPlatform nullable, blockGroupSchema.name nullable, getBlockGroups·getGroups·getGroup의 날짜 필드 nullable.
  • ResponseSchemaMismatchError가 새 error class로 분리 — instanceof ServerError && errorCode === 'ResponseSchemaMismatch' 분기는 더 이상 매치되지 않음.

Follow-up (본 PR 범위 밖)

  • request 측 BadRequestError, transport 측 ClientError/ServerError/NetworkError/DefaultError도 동일한 PII redact 정책 적용 (요구 시 별도 PR)
  • storedMessageSchemakakaoOptions/rcsOptions 등을 실제 서버 정규화 포맷 기반 상세 스키마로 확장 (요구 시 별도 PR)

Test plan

  • pnpm lint (biome)
  • pnpm test301 tests (신설 24건: decodeServerResponse unit test)
  • pnpm build (tsup CJS/ESM/DTS)
  • 실제 SOLAPI 서버에 15개 조회 API를 호출해 전수 검증: 모두 OK
  • 8라운드 교차 리뷰 (Codex + pr-review-toolkit) 반영 완료

Related

🤖 Generated with Claude Code

Palbahngmiyine and others added 10 commits April 17, 2026 19:31
- getBalance 응답에 lowBalanceAlert, minimumCash, rechargeTo, deposit 등 실서버에 존재하는 필드를 반영해 스키마 확장
- getStatistics, getGroups, getGroup, getBlacks, getBlockGroups, getKakaoChannel, getKakaoAlimtalkTemplate(s) 응답의 드리프트(nullable, 누락 필드, Record→Array 등) 정합화
- MessageTypeRecord에 rcs_itpl/ltpl, fax, voice, bms_* 신규 메시지 타입 추가
- 조회 응답 전용 storedMessageSchema 신설해 발송용 messageSchema와 분리
- DefaultService.requestEffect/getWithQuery에 responseSchema 주입 경로 마련하고, decodeServerResponse 헬퍼 추가로 서버 응답을 런타임 검증해 드리프트 즉시 감지
- 15개 조회 서비스 메서드에 해당 스키마 연결

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
첫 페이지 호출 시 서버가 startKey를 null 또는 미포함으로 반환하는데
getKakaoChannelsResponseSchema와 getKakaoAlimtalkTemplatesResponseSchema의
startKey가 required string으로 남아 있어 런타임 검증이 실패하는 회귀를 수정.
다른 list 응답들과 동일하게 NullishOr로 정렬.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 전용 ResponseSchemaMismatchError 도입 — 2xx에서 발생하는 스키마 불일치를
  ServerError(200)으로 래핑하던 문제를 분리해 5xx 재시도/알림 분기 오염 방지
- validationErrors(ArrayFormatter) 및 responseBody 보존으로 운영 환경에서도
  드리프트 경로 재현 가능
- decodeServerResponse에 onExcessProperty:'preserve' 적용 — 부분 스키마가
  서버의 미선언 필드를 strip 해버려 소비자 응답에서 silent data loss가
  발생하던 문제 수정
- storedMessageSchema의 autoTypeDetect/replacement/voiceReplied/
  unavailableSenderNumber를 Schema.transform으로 boolean 정규화
- responseSchema 제네릭을 Schema.Schema<R, I, never>로 타이트닝 —
  요구사항 채널이 오염되지 않도록 강제
- decodeServerResponse 8개 유닛 테스트 추가 (fixture 기반 회귀 방어)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- booleanOrZeroOne을 Schema.transformOrFail로 엄격화해 2/-1/NaN 같은
  drift 입력을 silent true로 coerce하지 않고 ResponseSchemaMismatchError로 전파
- stringifyResponseBody가 circular/BigInt로 JSON 실패 시 undefined로
  정보 소실하던 동작을 '[unserializable: reason] toStringTag' 메타데이터로 보완
- ResponseSchemaMismatchError.toString()의 production 경로에서 url과
  validationErrors를 유지(responseBody만 민감 페이로드라 계속 숨김)
- 엣지 케이스 유닛 테스트 추가: boolean transform 거부값, 순환참조 responseBody

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ure fields

Codex 2차 리뷰 발견 사항 반영:
- countForChargeSchema에 rcs_itpl/ltpl, fax, voice, bms_* 13개 필드 추가 —
  messageTypeRecordSchema 확장과 비대칭하여 신규 메시지 타입을 가진 그룹 조회가
  ResponseSchemaMismatchError로 실패하던 문제 수정 (getGroups/getGroup 회귀 방어)
- storedMessageSchema의 unavailableSenderNumber/faxPageCount/voiceDuration/
  voiceReplied를 NullishOr로 완화 — non-FAX/non-VOICE 메시지 행에서 null로
  내려오는 경우 전체 응답 decode 실패하던 문제 수정

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Error

Codex 3차 리뷰 P2: production에서 ResponseSchemaMismatchError의 responseBody
필드에 원본 응답이 유지되어, toString() 가드와 무관하게 Sentry 등
에러 리포터가 enumerable 필드를 직렬화하면서 PII가 누출될 수 있었음.
ServerError와 동일하게 creation 단계에서 production이면 undefined로 제외.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion

silent-failure-hunter 4차 후속:
- ParseResult.ArrayFormatter/TreeFormatter는 실패 메시지에 원본 값(전화번호 등)을
  그대로 문자열로 삽입하므로, production에서는 validationErrors 엔트리를
  `path: [_tag]` 형태로 축약하고 message는 issue 개수 요약으로 대체
- getMessages 등 조회 API는 to/from 등을 query string에 실으므로 production에서
  url의 query 부분을 '?[redacted]'로 마스킹
- 테스트 fixture를 확장해 실제 전화번호 문자열이 message/validationErrors/url
  어디에도 포함되지 않음을 명시적으로 검증

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
silent-failure-hunter 5차:
- NODE_ENV 가드를 safe-by-default로 반전 — staging/unset 등
  비정형 환경에서도 redact를 기본 적용하고, development/test만 verbose 모드
- redactUrlForProduction이 URL fragment(#...)를 먼저 strip 하도록 보완해
  fragment 내부 쿼리 문자열 또한 redact 경로에 포함
- ResponseSchemaMismatchError.toString()에서 이중 가드 제거 — creation 시점에
  이미 필드가 redact된 상태라 단순히 보유 값만 렌더링
- 테스트 시나리오를 'production' → 'staging'으로 교체해 새로운 가드 범위 검증

Codex 4차에서 지적된 BadRequestError/ClientError/ServerError/NetworkError의
동일 PII 누출 패턴은 request/transport 계층 전반의 정책 변경을 요구하므로
이 PR 범위에서 분리 — 별도 후속 작업으로 처리 권장

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
silent-failure-hunter/Codex 5차:
- NODE_ENV gate를 .trim().toLowerCase()로 정규화해 Windows PowerShell 등에서
  'Development' 같은 변형도 verbose 모드로 인식
- redactUrlForProduction을 URL 객체 기반으로 전환해 query string뿐 아니라
  fragment/userinfo(user:password@)까지 함께 redact
- ResponseSchemaMismatchError.toString()에 defense-in-depth 가드 복원 —
  클래스가 public이라 외부에서 직접 생성될 경우에도 production 경로에서
  responseBody가 유출되지 않도록 한다
- storedMessageSchema의 옵션 객체(kakao/rcs/naver/fax/voice)와 배열 필드
  (replacements/log/queues)를 Record/Array of Unknown으로 강화해 최소한의
  구조 계약(object or array)이 타입 수준에서 유지되도록 함
- 테스트: NODE_ENV를 table-driven으로 확장해 production/staging/undefined/빈 문자열
  모두 redact 되고 development/test만 verbose 되는 계약, 대소문자/공백 정규화
  동작, userinfo/fragment redact 동작까지 커버

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ypeRecord

Codex 최종 P2: statusCode 값 타입이 generic Record<string, number>로 바뀌면서
statusCode[code].sms/lms 같은 타입-narrowed 접근이 깨지던 회귀 복원.
서버가 실제 일부 필드만 내려주는 sparse 응답임을 고려해,
messageTypeRecordSchema를 Schema.partial로 감싼 partialMessageTypeRecordSchema를
신설하고 dayPeriod.statusCode value에 사용. 타입 정보는 유지하면서도
한두 필드만 있는 응답도 통과한다.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements runtime schema validation for API responses using Effect Schema, introducing a new ResponseSchemaMismatchError and a decodeServerResponse utility with PII redaction capabilities. Numerous models and services were updated to enforce these schemas, and a new storedMessageSchema was added to handle server-side type drift. Feedback was provided regarding a potential PII leak in the URL redaction fallback logic for malformed URLs containing user information.

Comment thread src/lib/schemaUtils.ts
Comment on lines +113 to +116
// 파싱 불가한 상대/비정상 URL은 보수적으로 첫 구분자 이후 전부 마스킹
const cut = url.search(/[?#;]/);
return cut === -1 ? url : `${url.slice(0, cut)}?[redacted]`;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

URL 파싱에 실패했을 때의 fallback 로직에서 userinfo(예: user:password@)가 포함된 경우 PII가 유출될 가능성이 있습니다. 현재는 ?, #, ; 문자만 체크하여 그 이후를 마스킹하고 있는데, URL의 권한(authority) 부분에 포함될 수 있는 민감 정보도 고려하는 것이 안전합니다. 파싱이 불가능한 비정상적인 URL 문자열이더라도 보수적으로 전체를 마스킹하거나, 최소한 프로토콜 구분자(//) 이후의 첫 번째 / 이전 구간에 @가 있는지 확인하여 마스킹하는 로직을 검토해 주세요.

@Palbahngmiyine Palbahngmiyine merged commit 4e4317b into solapi:beta Apr 17, 2026
6 checks passed
@Palbahngmiyine Palbahngmiyine deleted the sync/response-schema-validation-beta branch April 17, 2026 10:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant