REST API (v1)
PlantPulse Edge 의 v1 REST API 매뉴얼. 화면 / 외부 시스템이 OPC, 태그, 모니터링, 외부 전송 헬스체크를 다루는 표준 인터페이스 입니다.
| 항목 | 값 |
|---|---|
| Base URL | http(s)://<edge-host>/api/v1 |
| Content-Type | application/json; charset=utf-8 |
| 인증 | API Key 강제 (edge.rest.api.auth=true) — X-API-Key / Authorization: Bearer, 또는 로그인 세션으로 통과. ?api_key= 는 401 로 거부 |
| Audit 로그 | ApiAccessLogFilter — 모든 /api/* 호출을 한 줄로 기록 (메서드/경로/상태/지속/IP/사용자/auth 모드) |
| 메인 컨트롤러 | plantpulse.app.edge.api.v1.* (OpcAPI, TagAPI, AppAPI, SystemAPI, MonitoringAPI) |
| 오류 envelope | {data, meta, errors} — filter/advice 경로도 동일 |
| HTTPS | edge.rest.api.https_only=true 기본. plain HTTP client 호출은 거부 또는 Tomcat HTTPS redirect 정책을 따른다 |
인증 (ApiAuthFilter)
app.properties 에서 edge.rest.api.auth=true 로 활성화. 키는 edge.rest.api.key 이며 운영에서는 EDGE_REST_API_KEY_FILE 또는 EDGE_REST_API_KEY 로 주입합니다.
다음 3가지 중 하나라도 만족하면 통과:
A. 로그인 세션 통과 (브라우저)
이미 /login/form 으로 로그인한 사용자 — 세션에 _USER_LOGIN attribute (JSONObject) 가 있으면 ApiAuthFilter 가 그대로 chain.doFilter() 합니다. 화면(JSP) 에서 ajax 로 호출하는 모든 /api/* 가 추가 키 없이 동작합니다.
B. X-API-Key 헤더 (권장)
EDGE_REST_API_KEY="$(tr -d '\r\n' < /run/secrets/edge-rest-api-key)"
cfg="$(mktemp)"
trap 'rm -f "$cfg"' EXIT
printf 'header = "X-API-Key: %s"\n' "$EDGE_REST_API_KEY" > "$cfg"
curl --config "$cfg" https://edge.example.com/api/v1/tag/TAG_LS_XBC_0001/value
C. Authorization: Bearer <키> 헤더
EDGE_REST_API_KEY="$(tr -d '\r\n' < /run/secrets/edge-rest-api-key)"
cfg="$(mktemp)"
trap 'rm -f "$cfg"' EXIT
printf 'header = "Authorization: Bearer %s"\n' "$EDGE_REST_API_KEY" > "$cfg"
curl --config "$cfg" https://edge.example.com/api/v1/tag/TAG_LS_XBC_0001/value
D. ?api_key=<키> 쿼리 파라미터 — 거부됨
URL 에 키가 평문으로 들어가 액세스 로그/프록시 캐시/브라우저 history 에 남을 수 있으므로 v1 에서는 401 로 거부합니다. 헤더 방식(B/C)을 사용하세요.
실패 응답
HTTP/1.1 401 Unauthorized
Content-Type: application/json; charset=utf-8
{
"data": null,
"meta": {
"timestamp": 1778069486934,
"status": "ERROR",
"http_status": 401,
"request_id": "3b89b8f1-0fc1-4ab4-b4eb-d12dc314d500"
},
"errors": [
{
"code": "UNAUTHORIZED",
"message": "Invalid or missing X-API-Key."
}
]
}
키 비교는 constantTimeEquals() (timing-attack 방어).
edge.rest.api.auth=false (개발 / 임시)
인증을 끄면 ApiAuthFilter 가 즉시 chain.doFilter() — 외부에서 키 없이 호출 가능합니다. 운영망에서는 항상 true 권장.
Audit 로그 (ApiAccessLogFilter)
모든 /api/* 호출을 한 줄 로그로 기록 — 인증 실패(401) 도 포함되어 audit 트레일을 남깁니다 (필터 체인 순서: ApiAccessLogFilter → ApiAuthFilter).
형식
[API_ACCESS] method=GET path=/api/v1/tag/TAG_MS_1000/value query= status=200 duration=12ms
ip=100.127.147.98 user=admin auth=session ua=Mozilla/5.0...
| 필드 | 의미 |
|---|---|
method | HTTP 메서드 |
path | request URI (쿼리 미포함) |
query | 쿼리 문자열 — 거부되는 ?api_key=... 부분도 *** 로 마스킹 |
status | HTTP 응답 코드 |
duration | 처리 시간 (ms) |
ip | 기본은 RemoteAddr. edge.rest.api.trust_x_forwarded_for=true 인 reverse proxy 배치에서만 X-Forwarded-For 첫 IP |
user | session 사용자면 user_id / apikey 면 XXXX*** (len=16) (앞 4자 + 길이) / 익명이면 - |
auth | session (브라우저 로그인) / apikey (X-API-Key/Bearer) / deprecated_query (거부된 ?api_key= 시도) / none (키 누락 또는 익명) |
ua | User-Agent (80자 초과 시 … 잘라짐) |
error | 예외 발생 시 error=ClassName 추가 |
로그 레벨 분기
| 상태 | 레벨 | 의도 |
|---|---|---|
| 5xx 또는 throwable | ERROR | 서버 오류 — 즉시 알람 |
| 401, 403 | WARN | 인증/권한 거절 — audit 우선 |
| 그 외 (2xx, 3xx, 4xx 의 4xx 일반) | INFO | 일상 호출 |
활용
- 누가 키를 잘못 보내고 있나 —
grep '\[API_ACCESS\].*status=401'로 401 트레일 확인 - 5xx 회귀 추적 —
grep '\[API_ACCESS\].*status=5'+error=필드 - 호출 빈도 / 사용자별 집계 — auth=apikey/session 로 grouping, ip 별 분석
- 응답 지연 슬로우 쿼리 —
duration=큰 라인 추출
키 평문은 어디에서도 로그에 남지 않습니다 — query 마스킹 + user 필드 prefix 4자 만으로 식별. 침해 사고 분석 에는 충분한 식별성 + 평문 노출 방지의 균형.
web.xml 매핑 순서: ApiAccessLogFilter 가 먼저, 그 다음 ApiAuthFilter. AccessLog 가 finally 블록에서 status 를 기록하기 때문에 인증 실패한 401 호출도 그대로 audit 됩니다. 순서가 바뀌면 401 호출이 access log 에서 누락됩니다.
응답 envelope
v1 REST API 응답은 공통 envelope 으로 감쌉니다 (RestApiSupport, ApiEnvelopeWriter).
정상 응답:
{
"data": { },
"meta": {
"timestamp": 1778069486934,
"status": "OK",
"request_id": "f818abd0-eb0f-4497-9b07-7ccc94b2e2d7"
}
}
에러 응답:
{
"data": null,
"meta": {
"timestamp": 1778069486934,
"status": "ERROR",
"http_status": 400,
"request_id": "3b89b8f1-0fc1-4ab4-b4eb-d12dc314d500"
},
"errors": [
{
"code": "VALIDATION_FAILED",
"message": "Validation failed"
}
]
}
옛 RPC-style envelope (result, _session_id, _user_id) 는 REST v1 에서 사용하지 않습니다.
1. 공통 규약
엔드포인트를 보기 전에, 모든 /api/v1/* 이 함께 지키는 것들입니다. 이것을 모르면 개별 엔드포인트 문서를 읽어도 클라이언트가 틀립니다.
1.1 운영 헤더
| 헤더 | 방향 | 용도 |
|---|---|---|
X-API-Key | 요청 | 인증. ?api_key= 쿼리는 폐기(401) |
Authorization: Bearer <키> | 요청 | API 키의 대안 |
X-Request-Id | 요청 | 추적용. 응답 meta.request_id 로 echo 됩니다. 안 주면 서버가 만듭니다 |
Idempotency-Key | 요청 | POST 중복 방지. TTL 10분. 같은 키로 재시도하면 캐시된 응답과 함께 Idempotency-Replay: true 가 옵니다 |
If-Match | 요청 | 낙관적 동시성. GET 응답의 ETag 와 다르면 412 |
ETag | 응답 | weak validator(W/"<hex>"). 현재 GET /api/v1/opc/{opcId} 만 냅니다 |
Location | 응답 | POST 생성(201) 시 새 리소스 URI |
X-RateLimit-Limit · -Remaining · -Reset | 응답 | 레이트리밋 정보 |
Retry-After | 응답 | 429 일 때 기다릴 초 |
레이트리밋은 edge.rest.api.rate_limit.enabled 와 edge.rest.api.rate_limit.per_second(기본 1000)로 조절합니다.
Idempotency-Key 를 쓰십시오현장 회선은 끊깁니다. 응답을 못 받은 POST 는 「안 된 것」이 아니라 「모르는 것」입니다 — 그냥 다시 보내면 OPC 가 둘 생길 수 있습니다. 같은 키로 보내면 박스가 첫 번째 결과를 그대로 돌려줍니다.
1.2 페이지네이션
목록 엔드포인트의 공통 쿼리입니다.
| 쿼리 | 기본값 | 설명 |
|---|---|---|
page | 0 | 0부터 세는 페이지 번호 |
size | 0 | 0 이면 전체를 돌려줍니다. 1..1000 이면 그만큼 잘라서 주고 meta.pagination 을 채웁니다 |
size 가 1000 을 넘거나 음수, page 가 음수면 400 INVALID_RANGE 입니다.
1.3 ID 규칙
| 대상 | 정규식 |
|---|---|
site_id | ^SITE_[A-Z0-9_]{1,60}$ |
opc_id | ^OPC_[A-Z0-9_]{1,60}$ |
tag_id | ^TAG_[A-Z0-9_]{1,60}$ |
data_type 은 Boolean · Integer · Long · Float · Double · String 여섯뿐입니다(Cassandra ENUM 과 일치). opc_type 은 드라이버가 가진 프로토콜 상수 화이트리스트이고(LS · OPCUA · MODBUS · MODBUS_RTU · HTTP · MELSEC · S7 · EIP …), 목록의 정본은 GET /v3/api-docs 입니다. 화이트리스트 밖의 값은 field_errors[].reason = "not_in_whitelist" 로 거부됩니다.
2. 엔드포인트 일람
52개 전부입니다. OpenAPI 정본은 박스의 GET /v3/api-docs 이고, 화면으로 보려면 /public/swagger-ui/index.html 입니다.
2.1 OPC — 8
| Method | URL | 설명 | 응답 |
|---|---|---|---|
| GET | /api/v1/opc | OPC 목록 + 런타임 상태 + 60분 추이 | 200 · 400 |
| POST | /api/v1/opc | OPC 등록. tag_list 로 태그를 함께 넣을 수 있습니다 | 201 · 400 · 409 |
| GET | /api/v1/opc/{opcId} | 상세 + ETag. ?include=tags 로 태그 배열을 함께 | 200 · 404 |
| PUT | /api/v1/opc/{opcId} | 수정(If-Match 지원) | 200 · 404 · 412 |
| PATCH | /api/v1/opc/{opcId} | 부분 수정 — 보낸 필드만 바꿉니다 | 200 · 400 · 404 · 412 |
| DELETE | /api/v1/opc/{opcId} | OPC + 하위 태그까지 삭제 | 200 · 404 · 412 |
| POST | /api/v1/opc/{opcId}/start | 수집 시작 | 200 · 404 |
| POST | /api/v1/opc/{opcId}/stop | 수집 중지 | 200 · 404 |
POST 필수 7필드 — site_id · opc_id · opc_name · opc_type · opc_agent_ip · opc_agent_port(1~65535) · timecycle(100ms 이상). 위반은 한꺼번에 돌려줍니다(field_errors[]).
tag_list 로 태그를 함께 등록합니다. 연결 하나에 태그 수십 개를 넣는 것이 보통이라, 등록을 두 번 나누면 OPC 는 생겼는데 태그가 없는 중간 상태가 남습니다. 한 번에 보내십시오 — OPC 만 보내고 나중에 POST /api/v1/opc/{opcId}/tag 로 하나씩 붙이는 것도 됩니다.
2.2 Tag — 10
| Method | URL | 설명 | 응답 |
|---|---|---|---|
| GET | /api/v1/tag | 전체 태그 목록 | 200 · 400 |
| GET | /api/v1/opc/{opcId}/tag | 한 OPC 의 태그 목록 | 200 · 400 |
| POST | /api/v1/opc/{opcId}/tag | 태그 단건 등록 | 201 · 400 · 409 |
| GET | /api/v1/tag/{tagId} | 태그 상세 | 200 · 404 |
| PUT | /api/v1/tag/{tagId} | 태그 수정 | 200 · 404 |
| DELETE | /api/v1/tag/{tagId} | 태그 삭제 | 200 · 404 |
| GET | /api/v1/tag/{tagId}/value | 마지막 값. ?fresh=true 면 장치에서 새로 읽습니다 | 200 · 404 · 503 |
| POST | /api/v1/tag/{tagId}/value | 값 쓰기 + 쓴 뒤 다시 읽기(actual_read) | 200 · 400 · 404 · 503 |
| POST | /api/v1/tag-values:read | 여러 태그의 캐시 값을 한 번에(최대 100건) | 200 · 400 |
| GET | /api/v1/tag/{tagId}/points | 시계열 조회 + 다운샘플 | 200 · 400 · 500 |
POST 필수 6필드 — site_id · opc_id(경로에서 자동) · tag_id · tag_name · data_type · plc_address.
points 의 쿼리: from·to(epoch ms, from < to) · minutes(기본 10, 11440) · 5000 — 넘으면 등간격으로 솎아서 줍니다).limit(기본 600, 1
쓰기 실패의 코드 매핑 — 태그 없음 404 TAG_NOT_FOUND · 연결 끊김 503 DISCONNECTED · 수집 정지 503 OPC_STOPPED · 읽기전용 태그 400 RW_VIOLATION · 타입 불일치 400 TYPE_MISMATCH · 그 외 500 INTERNAL_ERROR.
2.3 앱(도커 컨테이너) — 9
| Method | URL | 설명 | 응답 |
|---|---|---|---|
| GET | /api/v1/app | 컨테이너 목록 | 200 · 400 |
| POST | /api/v1/app | 이미지 pull 후 run | 201 · 400 · 409 · 502 |
| GET | /api/v1/app/{name} | docker inspect 결과 | 200 · 404 |
| DELETE | /api/v1/app/{name} | 삭제(?image=true 면 이미지까지) | 200 · 404 |
| POST | /api/v1/app/{name}/start | 시작 | 200 · 404 |
| POST | /api/v1/app/{name}/stop | 중지 | 200 · 404 |
| POST | /api/v1/app/{name}/restart | 재시작 | 200 · 404 |
| GET | /api/v1/app/{name}/logs?lines=N | 마지막 N 줄(1~5000, 기본 200) | 200 · 404 |
| GET | /api/v1/app/{name}/stats | CPU · 메모리 · 네트워크 · 디스크 스냅샷 | 200 · 404 |
name 과 image 는 정규식 + 셸 메타문자 차단을 통과해야 합니다(; & | \ $ ( ) < > \ " ' 공백). memory_limit_mb는 16 이상,cpu_limit은 0.1 이상,network는bridge·host·none` 입니다.
배포 실패는 단계를 알려 줍니다 — pull 실패 502 DOCKER_PULL_FAILED, run 실패 502 DOCKER_RUN_FAILED, 같은 이름이 이미 있으면 409 CONTAINER_EXISTS.
2.4 시스템 — 12
| Method | URL | 설명 | 인증 |
|---|---|---|---|
| GET | /api/v1/system/health | liveness + 컴포넌트 8종 확인 | 불필요 |
| GET | /api/v1/system/ready | readiness(모니터 + V5 클라이언트) | 불필요 |
| GET | /api/v1/system/version | 버전 · 빌드일 · 이미지 태그 · 컨테이너 여부 | 불필요 |
| GET | /api/v1/system/time | 박스의 현재 시각(epoch ms) | 필요 |
| POST | /api/v1/system/time/sync | 플랫폼 시각에 맞춥니다({"platform_time":…}) | 필요 |
| GET | /api/v1/system/logs | 최근 로그를 JSON 으로 | 필요 |
| GET | /api/v1/system/logs/download | 로그 디렉터리를 tar.gz 로 | 필요 |
| GET | /api/v1/system/events | SSE 스트림(text/event-stream) | 필요 |
| GET | /api/v1/system/upgrade/check | 다운로드 서버의 최신 버전과 비교 | 필요 |
| POST | /api/v1/system/upgrade | 업그레이드 트리거 | 필요 |
| GET | /api/v1/system/upgrade/status | 업그레이드 진행 상태(polling) | 필요 |
| POST | /api/v1/system/firmware | 펌웨어 업데이트 트리거 | 필요 |
| POST | /api/v1/system/reboot | 5초 뒤 호스트 재부팅 | 강제 |
| POST | /api/v1/system/restart | 3초 뒤 엣지 에이전트 재시작 | 강제 |
reboot · restart 는 인증을 «끌 수 없습니다»edge.rest.api.auth=false 로 인증을 꺼 두어도 이 둘만은 세션 로그인이나 API 키를 요구합니다. 아무나 부를 수 있으면 그것 자체가 서비스 거부이기 때문입니다.
2.5 모니터링 · 엣지 — 4
| Method | URL | 설명 |
|---|---|---|
| GET | /api/v1/monitoring | 가동시간 · OPC 수 · 등록 태그 · 스캔 속도 |
| GET | /api/v1/edge | edge_id · site_id · 버전 · 호스트명 · 모드 |
| GET | /api/v1/edge/status | 엣지 상태 요약 |
| GET | /api/v1/transfer | 나가는 전송(MQTT · SparkplugB · 플랫폼 API)별 상태 |
monitoring 은 부팅 직후 모니터가 아직 없으면 503 MONITOR_NOT_READY 를 냅니다 — 고장이 아니라 「아직」입니다.
2.6 이중화(HA) — 4
| Method | URL | 설명 |
|---|---|---|
| GET | /api/v1/ha/status | 이 엣지의 쓰기 권한 · 모드(manual/auto) · 언제부터 · 상대 박스의 상태 |
| PUT | /api/v1/ha/write-authority | 이 엣지의 쓰기 권한을 올리거나 내립니다({"enabled":true}) |
| PUT | /api/v1/ha/failover/override | 자동 페일오버에 수동 개입 — auto 모드에서만(아니면 409 NOT_AUTO_MODE) |
| POST | /api/v1/ha/definitions | 태그 정의를 상대에게 복제 |
쓰기 권한 — 읽기 전에 알아야 할 것
무엇을 막습니까. 권한이 없는 엣지는 실 PLC 쓰기를 거절합니다(WRITE_NOT_AUTHORIZED). 설정 키는 edge.plc.write_authority 이고 없으면 true 입니다 — 갓 설치한 박스는 권한을 가진 채로 출발합니다.
HTTP 수집은 이 게이트를 지나지 않습니다 — 다만 «항상» 은 아닙니다opc_type 이 HTTP 인 연결은 장치로 내보내는 쓰기가 아니라 들어오는 수집입니다. 그래서 권한을 내린 엣지에서도 HTTP 수집은 계속됩니다 — 이중 쓰기 위험이 없기 때문입니다.
⚠ 부팅 직후에는 그 HTTP 쓰기도 잠깐 거부될 수 있습니다. 게이트는 opc_type 을 캐시에서 읽는데, 캐시가 아직 안 찼거나 조회가 실패하면 「실 PLC」로 간주하고 막습니다(안전한 쪽으로 넘어지는 의도된 동작입니다). 그리고 그 조회 실패는 로그를 남기지 않습니다.
그래서 「HTTP 는 계속된다더니 거부됐다」를 만나면 기동 직후인지 먼저 보십시오. 캐시가 차면 저절로 풀립니다 — 설정을 고칠 일이 아닙니다.
이 호출은 자기 박스만 바꿉니다. 상대에게 묻지도, 잠그지도, 넘겨받지도 않습니다. 조율하는 코드가 없습니다.
그래서 두 박스가 동시에 권한을 갖는 상태가 실제로 가능합니다. 순서를 지키십시오 —
- A 를 내린다 —
PUT /api/v1/ha/write-authority {"enabled":false} - A 가
false인 것을 확인한다 —GET /api/v1/ha/status - B 를 올린다
반대로 하면 그 사이에 둘 다 true 인 창이 생기고, 그 창에서 두 박스가 같은 PLC 에 씁니다.
GET /api/v1/ha/status 의 peer.write_authority 가 상대 값을 보여주기는 합니다. 그러나 그것은 관측이지 강제가 아닙니다 — 상대가 권한을 갖고 있어도 이 호출은 막히지 않습니다.
persisted:false 는 「실패」가 아니라 「재시작하면 사라진다」입니다토글은 두 단계입니다. ① 라이브 반영 — 게이트가 즉시 새 값을 봅니다(재시작 불필요). ② app.properties 영속화 — 이것이 실패해도 ①은 유지되고 persisted:false 로 보고합니다.
즉 지금은 먹었는데 다음 재시작에 옛 값으로 돌아갑니다. 권한을 내렸다고 믿고 재시작했더니 다시 올라와 있는 것이 현장에서 제일 물리기 쉬운 자리입니다. 재시작 전에 원인을 해결하십시오 — 대개 /etc/kopens/conf 의 쓰기 권한입니다. 진단은 응답이 아니라 서버 로그에 있습니다:
[HA] write-authority persist to app.properties failed — applied live only
since 는 「마지막으로 제어권이 옮겨간 시각」입니다. 값이 실제로 바뀐 경우에만 갱신되므로, 같은 값을 다시 보내도 움직이지 않습니다.
감사는 항상 남습니다 — 바뀌면 WARN, 안 바뀌어도 INFO 에 [no-op] 를 붙여 남기고 둘 다 호출자 IP 와 계정을 포함합니다.
[HA][AUDIT] write-authority false -> true by 10.0.0.5 (admin)
⚠ 전이 이력과 플랫폼 진단 푸시는 바뀔 때만이고, 그 둘이 실패해도 토글은 깨지지 않습니다(로그에만 남습니다). 「이력에 없다」가 「안 바뀌었다」는 아닙니다 — 확인은 status 로 하십시오.
엣지가 스스로 알려 주는 두 가지. 쌍을 감시하는 쪽이 어긋난 상태를 로그에 남깁니다. 설정을 고치지는 않습니다 — 운영자가 합니다.
[HA] DUAL WRITE AUTHORITY detected — both edges have edge.plc.write_authority=true.
[HA] NO WRITE AUTHORITY — both edges have edge.plc.write_authority=false.
뒤쪽은 어디에서도 PLC 쓰기가 안 되는 상태입니다 — 「둘 다 내린 뒤 올리는 것을 잊은」 경우입니다.
페일오버 수동 개입 — PUT /api/v1/ha/failover/override
자동 페일오버가 이 엣지에 대해 판단하는 것을 멈추고 역할을 고정합니다. 푼 뒤에야 자동 판단이 돌아옵니다.
role | 뜻 |
|---|---|
"WRITER" | 하트비트를 계속 쓰고 권한을 쥡니다 |
"STANDBY" | 쓰기를 멈추고 권한을 놓습니다 |
null | 개입을 해제합니다 — 자동 판단으로 돌려보냅니다 |
{ "data": { "override": "WRITER", "previous": null }, "meta": { "status": "OK" } }
auto모드 전용입니다. 자동 페일오버가 돌고 있지 않으면 409NOT_AUTO_MODE입니다 — 앞의write-authority는 두 모드 모두에서 되므로, 수동 운용 중이라면 그쪽을 쓰십시오.WRITER·STANDBY는 대소문자를 가리지 않습니다.- 값이 셋 중 하나가 아니면 400
VALIDATION_FAILED에field_errors:[{field:"role", reason:"invalid_value"}]가 옵니다.
시각 맞추기 — POST /api/v1/system/time/sync
{ "platform_time": 1789780169674 }
epoch 밀리초(양수)이고 숫자든 숫자 문자열이든 받습니다. platform_time 을 빼면 박스에 설정된 NTP 원본에서 맞춥니다 — 값을 보내는 것과 생략하는 것이 서로 다른 동작입니다.
응답은 {"applied":true,"method":"system","before":…,"after":…} 꼴입니다. before/after 를 비교하면 실제로 얼마나 틀어져 있었는지가 나옵니다.
시스템을 바꾸는 호출이라 본문을 보기 전에 인증부터 확인합니다. 키나 세션이 없으면 401 이고, 인증이 된 뒤에 본문이 틀리면 400 VALIDATION_FAILED 입니다. 순서가 반대인 API 와 헷갈리지 마십시오 — 잘못된 본문에 401 이 오면 그것은 본문 문제가 아닙니다.
2.7 백업 · 복구 — 2
| Method | URL | 설명 |
|---|---|---|
| POST | /api/v1/connect/backup | 모든 OPC + 모든 태그를 JSON 하나로 |
| POST | /api/v1/connect/restore | 그 JSON 을 그대로 적용(upsert) |
대칭입니다 — backup 응답을 그대로 restore 본문으로 보내면 됩니다. restore 응답의 skipped[] 를 반드시 보십시오: 개별 행이 실패해도 전체가 실패하지 않고 건너뛴 뒤 카운트만 줄어듭니다.
2.8 계산식 — 1
| Method | URL | 설명 |
|---|---|---|
| POST | /api/v1/tags/formula/preview | 계산식을 저장하기 전에 결과를 미리 봅니다 — 계산식 |
2.9 인증 없이 열리는 넷
ApiAuthFilter 를 통과하는 것은 이 넷뿐입니다. 어떤 OPC·태그 정보도 내보내지 않습니다.
GET /api/v1/system/health · GET /api/v1/system/ready · GET /api/v1/system/version · GET /api/health(버전 없는 별칭)
이 넷은 X-API-Key 가 없어도, 틀려도 200 을 돌려줍니다. 그래서 키가 제대로 바뀌었는지 확인할 때는 이 경로를 쓰면 안 됩니다 — GET /api/v1/opc 처럼 키가 필요한 경로로 확인하십시오.
/health 는 여덟 칸을 봅니다. 여덟이 전부 UP 이면 status=UP + 200, 하나라도 아니면 status=DEGRADED + 503 입니다.
{
"data": {
"status": "UP",
"uptime_ms": 1720008,
"components": {
"cassandra": "UP", "redis": "UP", "mqtt": "UP", "node_red": "UP",
"opc_ua": "UP", "edge_core": "UP", "tse": "UP", "grafana": "UP"
}
},
"meta": { "timestamp": 1778925572946, "request_id": "..." }
}
🔴 503 에도 components 가 그대로 들어옵니다. 그게 진단의 전부입니다 — 상태코드만 보고 본문을 버리면 여덟 중 무엇이 죽었는지 알 길이 없습니다.
세 칸은 이름만 보면 오해하기 쉬워 따로 적습니다.
| 칸 | 실제로 보는 것 |
|---|---|
edge_core | readiness 입니다 — 수집기·모니터가 떴는지. 부팅 레이스 중이면 DOWN 이고 그래서 health 가 503 입니다. 플랫폼 연결과 무관해서 STANDALONE 박스에서도 뜻이 있습니다 |
opc_ua | 포트가 고정이 아닙니다. app.properties 의 opc.ua.server.tcp.port 를 따릅니다(개발 박스는 4840, 기본은 12000). 예전에 12000 으로 하드코딩했다가 개발 박스가 영원히 DOWN 으로 보고된 적이 있습니다 |
grafana | 2026-07 이미지부터 HTTPS 입니다. 평문 TCP 로 찔러 보면 Grafana 가 10초마다 TLS handshake error … EOF 를 로그에 남깁니다 — 그래서 박스는 핸드셰이크를 끝까지 하는 프로브를 먼저 쓰고, 구형 스택을 위해 TCP 확인을 폴백으로 둡니다 |
cassandra(9042) · redis(6379) · mqtt(1883) · node_red(1880) · tse(7800) 는 고정 포트의 TCP 확인입니다.
/version 예시:
{
"data": {
"product_name": "PlantPulse Edge",
"version": "2026",
"build_date": "20260523",
"image_tag": "2026-20260523",
"container_mode": true
},
"meta": { "timestamp": 1778925572946, "request_id": "..." }
}
OTA upgrade.sh 가 /api/health 를 300초 동안 찔러 자동 롤백을 판단하고, 도커 HEALTHCHECK 도 이 경로를 씁니다.
2.10 API 가 아닌 것 — /ui/opcua/* · /config/*
박스에는 화면이 쓰는 경로도 있습니다. 새 클라이언트는 쓰지 마십시오.
/ui/opcua/info·/tree·/history— 내장 OPC UA 서버 화면 전용입니다. 제품의 v1 API 정본은 이것들을 폐기로 선언했습니다. 코드는 아직 답하지만 계약이 아닙니다./config/load·/save·/restart·/reboot·/upgrade·/firmware·/backup·/temp-clean·/platform/test— 관리자 화면이 쓰는 경로입니다. 같은 일을 하는/api/v1/system/*이 있으니 그쪽을 쓰십시오 — envelope 도 에러 코드도 v1 규약을 따릅니다./api/http(APIController) — 폐기.POST /api/v1/tag/{tagId}/value를 쓰십시오.
3. 요청 / 응답 예시
아래는 전부 현재 envelope 입니다. 옛 문서에 있던 {"result":"OK", ...} 모양의 예시는 제품과 맞지 않아 걷어냈습니다 — 그 모양으로 짠 클라이언트는 지금 동작하지 않습니다.
3.1 OPC 등록
curl -sk -X POST "https://<엣지>/api/v1/opc" \
-H "X-API-Key: <키>" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"site_id": "SITE_00001",
"opc_id": "OPC_LS_XBM_0001",
"opc_name": "1라인 LS XBM",
"opc_type": "LS",
"opc_agent_ip": "192.168.0.10",
"opc_agent_port": 2004,
"timecycle": 1000,
"tag_list": [
{ "site_id": "SITE_00001", "opc_id": "OPC_LS_XBM_0001",
"tag_id": "TAG_LS_XBM_0001", "tag_name": "1호기 토출압력",
"data_type": "Float", "plc_address": "%MW100" }
]
}'
tag_list 는 빼도 됩니다 — 그러면 OPC 만 생깁니다.
201 + Location: /api/v1/opc/OPC_LS_XBM_0001:
{
"data": { "info": { "opc_id": "OPC_LS_XBM_0001", "opc_name": "1라인 LS XBM" } },
"meta": { "timestamp": 1778925572946, "status": "OK", "request_id": "..." }
}
opc_agent_port 와 timecycle 은 숫자로 보내도 됩니다저장소의 해당 컬럼이 문자열이라 서버가 받아서 바꿔 넣습니다. 숫자로 보내고 문자열로 돌려받아도 이상한 것이 아닙니다.
3.2 삭제
{
"data": { "deleted": true, "opc_id": "OPC_X" },
"meta": { "timestamp": 1778925572946, "status": "OK", "request_id": "..." }
}
DELETE 도 200 + 본문입니다. Swagger 주석에 204 로 적힌 것이 있어도 실제 동작은 200 입니다 — v1 은 모든 메서드가 envelope 을 돌려줍니다.
3.3 실패는 이렇게 옵니다
POST /api/v1/opc/OPC_NONE/start — 404:
{
"meta": { "timestamp": 1778925572946, "status": "ERROR", "request_id": "..." },
"errors": [ { "code": "OPC_NOT_FOUND", "message": "OPC OPC_NONE 가 존재하지 않습니다." } ]
}
data 가 아예 없습니다(null 필드는 직렬화에서 생략됩니다). data 의 유무로 성패를 가르지 마시고 HTTP 상태나 meta.status 로 가르십시오.
3.4 여러 태그 값을 한 번에
curl -sk -X POST "https://<엣지>/api/v1/tag-values:read" \
-H "X-API-Key: <키>" -H "Content-Type: application/json" \
-d '{ "tag_ids": ["TAG_LS_XBM_0001", "TAG_LS_XBM_0002"] }'
{
"data": [
{ "tag_id": "TAG_LS_XBM_0001", "value": "1234", "timestamp": "2026-05-15T11:32:45.000Z", "quality": "SUCCESS" },
{ "tag_id": "TAG_LS_XBM_0002", "value": null, "timestamp": null, "quality": "PENDING" }
],
"meta": { "timestamp": 1778925572946, "status": "OK", "request_id": "..." }
}
🔴 quality 를 보십시오. PENDING 은 「값이 0」이 아니라 **「아직 한 번도 안 읽혔다」**입니다. 그것을 0으로 읽으면 아무도 들여다보지 않은 설비가 「정상」으로 보고됩니다.
배열이 비었거나 100건을 넘으면 400(MISSING_FIELD / TOO_MANY)입니다.
3.5 재기동
{
"data": { "message": "reboot scheduled in 5 seconds" },
"meta": { "timestamp": 1778925572946, "status": "OK", "request_id": "..." }
}
202 입니다 — 「했다」가 아니라 **「하기로 했다」**입니다. 응답을 받은 시점에는 아직 살아 있습니다.
3.6 아직 준비 안 됨
GET /api/v1/system/ready — 503:
{
"meta": { "timestamp": 1778925572946, "status": "ERROR", "request_id": "..." },
"errors": [ { "code": "NOT_READY", "message": "엣지의 일부 구성요소가 준비되지 않았습니다." } ]
}
3.7 백업과 복구는 대칭입니다
{
"data": {
"opcs": [ { "opc_id": "OPC_LS_XBM_0001", "opc_name": "..." } ],
"tags": [ { "tag_id": "TAG_LS_XBM_0001", "opc_id": "OPC_LS_XBM_0001" } ],
"count": { "opc": 1, "tag": 1 },
"exported_at": 1778925572946
},
"meta": { "timestamp": 1778925572946, "status": "OK", "request_id": "..." }
}
data 를 그대로 POST /api/v1/connect/restore 의 본문으로 보내면 됩니다. 복구 응답의 skipped[] 를 반드시 확인하십시오 — 개별 행이 실패해도 전체는 200 이고, 건너뛴 것만 거기 적힙니다. count 만 보면 조용히 덜 복구됩니다.
4. 에러 코드
🔴 v1 은 실패를 HTTP 상태로 말합니다. 옛 RPC 방식({result:"ERROR"} 를 HTTP 200 으로 감싸던 것)은 폐기됐습니다. 상태코드를 안 보고 본문의 result 를 찾는 클라이언트는 모든 실패를 성공으로 읽습니다.
판정은 두 가지 중 하나로 하십시오 — HTTP 상태코드, 또는 meta.status("OK" / "ERROR"). errors[] 를 뒤질 필요 없이 한 키로 갈립니다.
| code | HTTP | 뜻 |
|---|---|---|
MISSING_BODY | 400 | 요청 본문이 비었습니다 |
MISSING_FIELD | 400 | 필수 필드 누락 |
INVALID_JSON | 400 | JSON 파싱 실패 |
INVALID_RANGE | 400 | page·size·from~to 범위 위반 |
INVALID_ARGUMENT | 400 | 일반 인자 오류(이미지·볼륨 화이트리스트 등) |
INVALID_NAME | 400 | 컨테이너 이름에 인젝션 문자 |
VALIDATION_FAILED | 400 | 여러 필드가 한꺼번에 틀렸습니다 — field_errors[] 를 보십시오 |
TOO_MANY | 400 | 일괄 요청 한도(100건) 초과 |
RW_VIOLATION · TYPE_MISMATCH | 400 | 태그 쓰기 실패 — 읽기전용 태그 / 타입 불일치 |
UNAUTHORIZED | 401 | 인증 누락 또는 무효 |
HTTPS_REQUIRED | 403 | 평문 HTTP 로 왔습니다 — 키 문제가 아닙니다 |
OPC_NOT_FOUND · TAG_NOT_FOUND · APP_NOT_FOUND | 404 | 대상이 없습니다 |
CONTAINER_EXISTS | 409 | 같은 이름의 컨테이너가 이미 있습니다 |
PRECONDITION_FAILED | 412 | If-Match 의 ETag 가 다릅니다 — 그 사이 누가 고쳤습니다 |
START_FAILED · STOP_FAILED | 422 | OPC 수집 제어 실패 |
DOCKER_PULL_FAILED · DOCKER_RUN_FAILED | 502 | 도커 명령 실패 |
MONITOR_NOT_READY · NOT_READY · DISCONNECTED · OPC_STOPPED | 503 | 아직/끊김 — 요청이 틀린 것이 아닙니다 |
MISCONFIGURED | 500 | edge.rest.api.key 가 비었습니다 |
INTERNAL_ERROR · *_FAILED | 500 | 그 밖의 내부 오류 |
DISCONNECTED · OPC_STOPPED · MONITOR_NOT_READY 는 요청이 틀려서가 아니라 지금 못 해서입니다. 같은 요청을 나중에 다시 보내면 됩니다. 400 처럼 「고쳐서 다시」로 처리하면 고칠 것이 없는데 고치려 들게 됩니다.
5. 동작 메모
- 태그 단건 조회:
APP_TAG.xml의R03(WHERE TAG_ID = :tag_id ALLOW FILTERING). - OPC 등록 · 태그 등록 뒤에는
ConnectService.restartComponents()가 돌아 캐시를 다시 읽고 OPC-UA 서버를 재시작합니다 — 등록 직후 잠깐 수집이 끊기는 것은 이 때문입니다. - 값 쓰기 (
tagWriteByTagId): HTTP 는HTTPDriver.bind(), 그 외는driver.write(ProtocolAddress). 실패 시 (ok=false) 친절 힌트 (EIP 펌웨어 호환성 등) 포함. write 후 즉시 read 한 결과를actual_read로 함께 반환 (HTTP 제외). - 트랜스퍼 헬스 (
/api/v1/transfer):TransferRegistry에 등록된 모든 outbound 컴포넌트의getStatus()를 모음. Sparkplug B 카드 (/ui/main) 가 5초 주기로 호출. - OPC UA 서버 (
/ui/opcua/*) 는 내부망 폴링 (2초) —OPCAndTagsCache.getInstance()+LastValueMap.getInstance()만 사용해 부하 적음. - 레거시 deprecated:
/api/http(APIController) —/api/v1/tag/{tagId}/value사용 권장.
6. 코드 위치
- 컨트롤러:
src/plantpulse/app/edge/api/v1/—OpcAPI·TagAPI·AppAPI·SystemAPI·SystemTimeAPI·MonitoringAPI·EdgeStatusAPI·HaAPI·ConnectIoAPI·FormulaAPI - MCP 서버:
src/plantpulse/app/edge/module/mcp/— MCP 서버 - envelope · 공통 응답:
api/v1/RestApiSupport.java·api/v1/dto/Api*.java - OPC UA 서버:
src/plantpulse/app/edge/module/opcua/OPCUAViewerController.java - 업그레이드 프록시:
src/plantpulse/app/edge/module/system/config/ConfigController.java - 비즈니스 로직:
src/plantpulse/app/edge/module/connect/ConnectService.java - 응답 envelope helper:
plantpulse.app.edge.core.web.ControllerSupport(ok()/error())