create-entity-server 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/bin/create.js +280 -0
  2. package/package.json +42 -0
  3. package/template/.env.example +14 -0
  4. package/template/configs/cache.json +22 -0
  5. package/template/configs/cors.json +7 -0
  6. package/template/configs/database.json +23 -0
  7. package/template/configs/jwt.json +7 -0
  8. package/template/configs/logging.json +45 -0
  9. package/template/configs/security.json +21 -0
  10. package/template/configs/server.json +10 -0
  11. package/template/entities/Account/account_audit.json +17 -0
  12. package/template/entities/Auth/account.json +60 -0
  13. package/template/entities/Auth/api_keys.json +26 -0
  14. package/template/entities/Auth/license.json +36 -0
  15. package/template/entities/Auth/rbac_roles.json +76 -0
  16. package/template/entities/README.md +380 -0
  17. package/template/entities/System/system_audit_log.json +65 -0
  18. package/template/entities/company.json +22 -0
  19. package/template/entities/product.json +36 -0
  20. package/template/entities/todo.json +16 -0
  21. package/template/samples/README.md +65 -0
  22. package/template/samples/flutter/lib/entity_server_client.dart +218 -0
  23. package/template/samples/flutter/pubspec.yaml +14 -0
  24. package/template/samples/java/EntityServerClient.java +304 -0
  25. package/template/samples/java/EntityServerExample.java +49 -0
  26. package/template/samples/kotlin/EntityServerClient.kt +194 -0
  27. package/template/samples/node/package.json +16 -0
  28. package/template/samples/node/src/EntityServerClient.js +246 -0
  29. package/template/samples/node/src/example.js +39 -0
  30. package/template/samples/php/ci4/Controllers/ProductController.php +141 -0
  31. package/template/samples/php/ci4/Libraries/EntityServer.php +260 -0
  32. package/template/samples/php/laravel/Http/Controllers/ProductController.php +62 -0
  33. package/template/samples/php/laravel/Services/EntityServerService.php +210 -0
  34. package/template/samples/python/entity_server.py +225 -0
  35. package/template/samples/python/example.py +50 -0
  36. package/template/samples/react/src/api/entityServerClient.ts +290 -0
  37. package/template/samples/react/src/example.tsx +127 -0
  38. package/template/samples/react/src/hooks/useEntity.ts +105 -0
  39. package/template/samples/swift/EntityServerClient.swift +221 -0
  40. package/template/scripts/api-key.ps1 +123 -0
  41. package/template/scripts/api-key.sh +130 -0
  42. package/template/scripts/cleanup-history.ps1 +69 -0
  43. package/template/scripts/cleanup-history.sh +54 -0
  44. package/template/scripts/cli.ps1 +24 -0
  45. package/template/scripts/cli.sh +27 -0
  46. package/template/scripts/entity.ps1 +70 -0
  47. package/template/scripts/entity.sh +72 -0
  48. package/template/scripts/generate-env-keys.ps1 +125 -0
  49. package/template/scripts/generate-env-keys.sh +148 -0
  50. package/template/scripts/install-systemd.sh +222 -0
  51. package/template/scripts/normalize-entities.ps1 +87 -0
  52. package/template/scripts/normalize-entities.sh +132 -0
  53. package/template/scripts/rbac-role.ps1 +124 -0
  54. package/template/scripts/rbac-role.sh +127 -0
  55. package/template/scripts/remove-systemd.sh +158 -0
  56. package/template/scripts/reset-all.ps1 +83 -0
  57. package/template/scripts/reset-all.sh +95 -0
  58. package/template/scripts/run.ps1 +239 -0
  59. package/template/scripts/run.sh +315 -0
  60. package/template/scripts/sync.ps1 +145 -0
  61. package/template/scripts/sync.sh +178 -0
  62. package/template/scripts/update-server.ps1 +117 -0
  63. package/template/scripts/update-server.sh +165 -0
@@ -0,0 +1,218 @@
1
+ /// Entity Server 클라이언트 (Flutter / Dart)
2
+ ///
3
+ /// 의존성 (pubspec.yaml):
4
+ /// dependencies:
5
+ /// http: ^1.2.1
6
+ /// cryptography: ^2.7.0
7
+ /// uuid: ^4.4.0
8
+ ///
9
+ /// HMAC API Key 인증 방식 사용 예:
10
+ /// ```dart
11
+ /// final client = EntityServerClient(
12
+ /// baseUrl: 'http://your-server:47200',
13
+ /// apiKey: 'your-api-key',
14
+ /// hmacSecret: 'your-hmac-secret',
15
+ /// magicLen: 4, // 서버 packet_magic_len 과 동일
16
+ /// );
17
+ /// final result = await client.list('product');
18
+ /// ```
19
+ ///
20
+ /// 트랜잭션 사용 예:
21
+ /// ```dart
22
+ /// await client.transStart();
23
+ /// try {
24
+ /// final orderRef = await client.submit('order', {'user_seq': 1, 'total': 9900}); // seq: "\$tx.0"
25
+ /// await client.submit('order_item', {'order_seq': orderRef['seq'], 'item_seq': 5}); // "\$tx.0" 자동 치환
26
+ /// final result = await client.transCommit();
27
+ /// final orderSeq = (result['results'] as List)[0]['seq']; // 실제 seq
28
+ /// } catch (e) {
29
+ /// await client.transRollback();
30
+ /// }
31
+ /// ```
32
+
33
+ import 'dart:convert';
34
+ import 'dart:typed_data';
35
+
36
+ import 'package:cryptography/cryptography.dart';
37
+ import 'package:http/http.dart' as http;
38
+ import 'package:uuid/uuid.dart';
39
+
40
+ class EntityServerClient {
41
+ final String baseUrl;
42
+ final String apiKey;
43
+ final String hmacSecret;
44
+ final int magicLen;
45
+
46
+ final _uuid = const Uuid();
47
+ String? _activeTxId;
48
+
49
+ EntityServerClient({
50
+ this.baseUrl = 'http://localhost:47200',
51
+ this.apiKey = '',
52
+ this.hmacSecret = '',
53
+ this.magicLen = 4,
54
+ });
55
+
56
+ // ─── CRUD ─────────────────────────────────────────────────────────
57
+
58
+ Future<Map<String, dynamic>> get(String entity, int seq) =>
59
+ _request('GET', '/v1/entity/$entity/$seq');
60
+
61
+ Future<Map<String, dynamic>> list(String entity, {int page = 1, int limit = 20}) =>
62
+ _request('GET', '/v1/entity/$entity/list?page=$page&limit=$limit');
63
+
64
+ Future<Map<String, dynamic>> count(String entity) =>
65
+ _request('GET', '/v1/entity/$entity/count');
66
+
67
+ Future<Map<String, dynamic>> query(
68
+ String entity,
69
+ List<Map<String, dynamic>> filter, {
70
+ int page = 1,
71
+ int limit = 20,
72
+ }) =>
73
+ _request('POST', '/v1/entity/$entity/query?page=$page&limit=$limit',
74
+ body: filter);
75
+
76
+ /// 트랜잭션 시작 — 서버에 트랜잭션 큐를 등록하고 transaction_id 를 반환합니다.
77
+ /// 이후 submit / delete 가 서버 큐에 쌓이고 transCommit() 시 일괄 처리됩니다.
78
+ Future<String> transStart() async {
79
+ final res = await _request('POST', '/v1/transaction/start');
80
+ _activeTxId = res['transaction_id'] as String;
81
+ return _activeTxId!;
82
+ }
83
+
84
+ /// 트랜잭션 전체 롤백
85
+ /// [transactionId] 생략 시 transStart() 로 시작한 활성 트랜잭션을 롤백합니다.
86
+ Future<Map<String, dynamic>> transRollback([String? transactionId]) {
87
+ final txId = transactionId ?? _activeTxId;
88
+ if (txId == null) throw StateError('No active transaction. Call transStart() first.');
89
+ _activeTxId = null;
90
+ return _request('POST', '/v1/transaction/rollback/$txId');
91
+ }
92
+
93
+ /// 트랜잭션 커밋 — 서버 큐에 쌓인 작업을 단일 DB 트랜잭션으로 일괄 처리합니다.
94
+ /// [transactionId] 생략 시 transStart() 로 시작한 활성 트랜잭션을 사용합니다.
95
+ Future<Map<String, dynamic>> transCommit([String? transactionId]) {
96
+ final txId = transactionId ?? _activeTxId;
97
+ if (txId == null) throw StateError('No active transaction. Call transStart() first.');
98
+ _activeTxId = null;
99
+ return _request('POST', '/v1/transaction/commit/$txId');
100
+ }
101
+
102
+ Future<Map<String, dynamic>> submit(
103
+ String entity,
104
+ Map<String, dynamic> data, {
105
+ String? transactionId,
106
+ }) {
107
+ final txId = transactionId ?? _activeTxId;
108
+ return _request('POST', '/v1/entity/$entity/submit',
109
+ body: data,
110
+ extraHeaders: txId != null ? {'X-Transaction-ID': txId} : null);
111
+ }
112
+
113
+ Future<Map<String, dynamic>> delete(String entity, int seq,
114
+ {String? transactionId, bool hard = false}) {
115
+ final q = hard ? '?hard=true' : '';
116
+ final txId = transactionId ?? _activeTxId;
117
+ return _request('DELETE', '/v1/entity/$entity/delete/$seq$q',
118
+ extraHeaders: txId != null ? {'X-Transaction-ID': txId} : null);
119
+ }
120
+
121
+ Future<Map<String, dynamic>> history(String entity, int seq,
122
+ {int page = 1, int limit = 50}) =>
123
+ _request('GET', '/v1/entity/$entity/history/$seq?page=$page&limit=$limit');
124
+
125
+ Future<Map<String, dynamic>> rollback(String entity, int historySeq) =>
126
+ _request('POST', '/v1/entity/$entity/rollback/$historySeq');
127
+
128
+ // ─── 내부 ─────────────────────────────────────────────────────────
129
+
130
+ Future<Map<String, dynamic>> _request(
131
+ String method,
132
+ String path, {
133
+ Object? body,
134
+ Map<String, String>? extraHeaders,
135
+ }) async {
136
+ final bodyStr = body != null ? jsonEncode(body) : '';
137
+ final timestamp =
138
+ (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString();
139
+ final nonce = _uuid.v4();
140
+ final signature = await _sign(method, path, timestamp, nonce, bodyStr);
141
+
142
+ final uri = Uri.parse('${baseUrl.replaceAll(RegExp(r'/$'), '')}$path');
143
+ final headers = <String, String>{
144
+ 'Content-Type': 'application/json',
145
+ 'X-API-Key': apiKey,
146
+ 'X-Timestamp': timestamp,
147
+ 'X-Nonce': nonce,
148
+ 'X-Signature': signature,
149
+ if (extraHeaders != null) ...extraHeaders,
150
+ };
151
+
152
+ final http.Response res;
153
+ switch (method.toUpperCase()) {
154
+ case 'GET':
155
+ res = await http.get(uri, headers: headers);
156
+ break;
157
+ case 'DELETE':
158
+ res = await http.delete(uri, headers: headers,
159
+ body: bodyStr.isNotEmpty ? bodyStr : null);
160
+ break;
161
+ default:
162
+ res = await http.post(uri, headers: headers,
163
+ body: bodyStr.isNotEmpty ? bodyStr : null);
164
+ }
165
+
166
+ final contentType = res.headers['content-type'] ?? '';
167
+
168
+ // 패킷 암호화 응답: application/octet-stream → 복호화
169
+ if (contentType.contains('application/octet-stream')) {
170
+ return await _decryptPacket(res.bodyBytes);
171
+ }
172
+
173
+ final data = jsonDecode(res.body) as Map<String, dynamic>;
174
+ if (data['ok'] != true) {
175
+ throw Exception('EntityServer error: ${data['message']} (HTTP ${res.statusCode})');
176
+ }
177
+ return data;
178
+ }
179
+
180
+ /// XChaCha20-Poly1305 패킷 복호화
181
+ /// 포맷: [magic:magicLen][nonce:24][ciphertext+tag:...]
182
+ /// 키: sha256(hmac_secret)
183
+ Future<Map<String, dynamic>> _decryptPacket(Uint8List data) async {
184
+ // 키 유도: sha256(hmac_secret)
185
+ final sha256 = Sha256();
186
+ final keyHash = await sha256.hash(utf8.encode(hmacSecret));
187
+ final key = SecretKey(keyHash.bytes);
188
+
189
+ final nonce = data.sublist(magicLen, magicLen + 24);
190
+ // ciphertext 마지막 16바이트가 Poly1305 MAC
191
+ final ciphertextWithMac = data.sublist(magicLen + 24);
192
+
193
+ final algorithm = Xchacha20.poly1305Aead();
194
+ final secretBox = SecretBox(
195
+ ciphertextWithMac.sublist(0, ciphertextWithMac.length - 16),
196
+ nonce: nonce,
197
+ mac: Mac(ciphertextWithMac.sublist(ciphertextWithMac.length - 16)),
198
+ );
199
+
200
+ final plaintext = await algorithm.decrypt(secretBox, secretKey: key);
201
+ return jsonDecode(utf8.decode(plaintext)) as Map<String, dynamic>;
202
+ }
203
+
204
+ /// HMAC-SHA256 서명
205
+ Future<String> _sign(
206
+ String method,
207
+ String path,
208
+ String timestamp,
209
+ String nonce,
210
+ String body,
211
+ ) async {
212
+ final payload = [method, path, timestamp, nonce, body].join('|');
213
+ final algorithm = Hmac.sha256();
214
+ final key = SecretKey(utf8.encode(hmacSecret));
215
+ final mac = await algorithm.calculateMac(utf8.encode(payload), secretKey: key);
216
+ return mac.bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
217
+ }
218
+ }
@@ -0,0 +1,14 @@
1
+ name: entity_server_client
2
+ description: Entity Server client library for Flutter
3
+ version: 1.0.0
4
+
5
+ environment:
6
+ sdk: ">=3.0.0 <4.0.0"
7
+ flutter: ">=3.10.0"
8
+
9
+ dependencies:
10
+ flutter:
11
+ sdk: flutter
12
+ http: ^1.2.1
13
+ cryptography: ^2.7.0
14
+ uuid: ^4.4.0
@@ -0,0 +1,304 @@
1
+ package com.example.entityserver;
2
+
3
+ import org.bouncycastle.crypto.modes.ChaCha20Poly1305;
4
+ import org.bouncycastle.crypto.params.AEADParameters;
5
+ import org.bouncycastle.crypto.params.KeyParameter;
6
+
7
+ import javax.crypto.Mac;
8
+ import javax.crypto.spec.SecretKeySpec;
9
+ import java.io.ByteArrayOutputStream;
10
+ import java.io.IOException;
11
+ import java.io.InputStream;
12
+ import java.io.OutputStream;
13
+ import java.net.HttpURLConnection;
14
+ import java.net.URL;
15
+ import java.nio.charset.StandardCharsets;
16
+ import java.security.MessageDigest;
17
+ import java.util.Arrays;
18
+ import java.util.Collections;
19
+ import java.util.HashMap;
20
+ import java.util.HexFormat;
21
+ import java.util.Map;
22
+ import java.util.UUID;
23
+
24
+ /**
25
+ * Entity Server 클라이언트 (Java)
26
+ *
27
+ * 의존성 (build.gradle / pom.xml):
28
+ * implementation("org.bouncycastle:bcprov-jdk18on:1.80") // XChaCha20-Poly1305
29
+ *
30
+ * 환경변수 또는 생성자로 설정:
31
+ * ENTITY_SERVER_URL http://localhost:47200
32
+ * ENTITY_SERVER_API_KEY your-api-key
33
+ * ENTITY_SERVER_HMAC_SECRET your-hmac-secret
34
+ * ENTITY_PACKET_MAGIC_LEN 4 (서버 packet_magic_len 과 동일)
35
+ *
36
+ * 사용 예:
37
+ * EntityServerClient es = new EntityServerClient();
38
+ * String result = es.get("account", 1);
39
+ * String list = es.list("account", 1, 20, null);
40
+ * String seq = es.submit("account", "{\"name\":\"홍길동\"}");
41
+ *
42
+ * 반환값은 JSON 문자열입니다. Gson / Jackson 등으로 파싱하세요.
43
+ *
44
+ * 트랜잭션 사용 예:
45
+ * es.transStart();
46
+ * try {
47
+ * String orderJson = es.submit("order", "{\"user_seq\":1,\"total\":9900}"); // seq: "$tx.0"
48
+ * // Gson 파싱 후 orderRef["seq"] 가 "$tx.0" — commit 시 실제 값으로 치환됨
49
+ * es.submit("order_item", "{\"order_seq\":\"$tx.0\",\"item_seq\":5}"); // "$tx.0" 자동 치환
50
+ * String commitResult = es.transCommit();
51
+ * // commitResult["results"][0]["seq"] 가 실제 order seq
52
+ * } catch (Exception e) {
53
+ * es.transRollback();
54
+ * }
55
+ */
56
+ public class EntityServerClient {
57
+
58
+ private final String baseUrl;
59
+ private final String apiKey;
60
+ private final String hmacSecret;
61
+ private final int timeoutMs;
62
+ private final int magicLen;
63
+ private String activeTxId = null;
64
+
65
+ public EntityServerClient() {
66
+ this(
67
+ getEnv("ENTITY_SERVER_URL", "http://localhost:47200"),
68
+ getEnv("ENTITY_SERVER_API_KEY", ""),
69
+ getEnv("ENTITY_SERVER_HMAC_SECRET", ""),
70
+ 10_000,
71
+ Integer.parseInt(getEnv("ENTITY_PACKET_MAGIC_LEN", "4"))
72
+ );
73
+ }
74
+
75
+ public EntityServerClient(String baseUrl, String apiKey, String hmacSecret, int timeoutMs, int magicLen) {
76
+ this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
77
+ this.apiKey = apiKey;
78
+ this.hmacSecret = hmacSecret;
79
+ this.timeoutMs = timeoutMs;
80
+ this.magicLen = magicLen;
81
+ }
82
+
83
+ // ─── CRUD ────────────────────────────────────────────────────────────────
84
+
85
+ /** 단건 조회 */
86
+ public String get(String entity, long seq) throws IOException {
87
+ return request("GET", "/v1/entity/" + entity + "/" + seq, null);
88
+ }
89
+
90
+ /** 목록 조회 */
91
+ public String list(String entity, int page, int limit, String orderBy) throws IOException {
92
+ String query = "?page=" + page + "&limit=" + limit + (orderBy != null ? "&order_by=" + orderBy : "");
93
+ return request("GET", "/v1/entity/" + entity + "/list" + query, null);
94
+ }
95
+
96
+ /** 건수 조회 */
97
+ public String count(String entity) throws IOException {
98
+ return request("GET", "/v1/entity/" + entity + "/count", null);
99
+ }
100
+
101
+ /**
102
+ * 필터 검색
103
+ * @param filterJson JSON 배열 문자열. 예: [{"field":"status","op":"eq","value":"active"}]
104
+ */
105
+ public String query(String entity, String filterJson, int page, int limit) throws IOException {
106
+ String query = "?page=" + page + "&limit=" + limit;
107
+ return request("POST", "/v1/entity/" + entity + "/query" + query, filterJson);
108
+ }
109
+
110
+ /**
111
+ * 트랜잭션 시작 — 서버에 큐를 등록하고 txId 를 저장합니다.
112
+ * 이후 submit / delete 가 실제 실행되지 않고 서버 큐에 쌓입니다.
113
+ * transCommit() 시 한 번에 DB 트랜잭션으로 실행됩니다.
114
+ */
115
+ public String transStart() throws IOException {
116
+ String json = request("POST", "/v1/transaction/start", null, Collections.emptyMap());
117
+ java.util.regex.Matcher m = java.util.regex.Pattern
118
+ .compile("\"transaction_id\"\\s*:\\s*\"([^\"]+)\"")
119
+ .matcher(json);
120
+ if (!m.find()) throw new IOException("transStart: transaction_id not found in response");
121
+ activeTxId = m.group(1);
122
+ return activeTxId;
123
+ }
124
+
125
+ /** 활성 트랜잭션 롤백 */
126
+ public String transRollback() throws IOException {
127
+ return transRollback(null);
128
+ }
129
+
130
+ /**
131
+ * 트랜잭션 롤백
132
+ * transactionId 가 null 이면 transStart() 로 시작한 활성 트랜잭션을 롤백합니다.
133
+ */
134
+ public String transRollback(String transactionId) throws IOException {
135
+ String txId = transactionId != null ? transactionId : activeTxId;
136
+ if (txId == null) throw new IllegalStateException("No active transaction. Call transStart() first.");
137
+ activeTxId = null;
138
+ return request("POST", "/v1/transaction/rollback/" + txId, null, Collections.emptyMap());
139
+ }
140
+
141
+ /** 트랜잭션 커밋 (activeTxId 사용) */
142
+ public String transCommit() throws IOException {
143
+ return transCommit(null);
144
+ }
145
+
146
+ /**
147
+ * 트랜잭션 커밋 — 큐에 쌓인 모든 작업을 단일 DB 트랜잭션으로 일괄 실행합니다.
148
+ * transactionId 가 null 이면 transStart() 로 시작한 활성 트랜잭션을 커밋합니다.
149
+ */
150
+ public String transCommit(String transactionId) throws IOException {
151
+ String txId = transactionId != null ? transactionId : activeTxId;
152
+ if (txId == null) throw new IllegalStateException("No active transaction. Call transStart() first.");
153
+ activeTxId = null;
154
+ return request("POST", "/v1/transaction/commit/" + txId, null, Collections.emptyMap());
155
+ }
156
+
157
+ /**
158
+ * 생성 또는 수정
159
+ * @param dataJson JSON 객체 문자열. seq 포함 시 수정, 없으면 생성.
160
+ */
161
+ public String submit(String entity, String dataJson) throws IOException {
162
+ return submit(entity, dataJson, null);
163
+ }
164
+
165
+ /** 생성 또는 수정 (트랜잭션 지원) */
166
+ public String submit(String entity, String dataJson, String transactionId) throws IOException {
167
+ Map<String, String> extra = new HashMap<>();
168
+ String txId = transactionId != null ? transactionId : activeTxId;
169
+ if (txId != null) extra.put("X-Transaction-ID", txId);
170
+ return request("POST", "/v1/entity/" + entity + "/submit", dataJson, extra);
171
+ }
172
+
173
+ /** 삭제 */
174
+ public String delete(String entity, long seq) throws IOException {
175
+ return delete(entity, seq, null, false);
176
+ }
177
+
178
+ /** 삭제 (트랜잭션 지원) */
179
+ public String delete(String entity, long seq, String transactionId, boolean hard) throws IOException {
180
+ String q = hard ? "?hard=true" : "";
181
+ Map<String, String> extra = new HashMap<>();
182
+ String txId = transactionId != null ? transactionId : activeTxId;
183
+ if (txId != null) extra.put("X-Transaction-ID", txId);
184
+ return request("DELETE", "/v1/entity/" + entity + "/delete/" + seq + q, null, extra);
185
+ }
186
+
187
+ /** 변경 이력 조회 */
188
+ public String history(String entity, long seq, int page, int limit) throws IOException {
189
+ String query = "?page=" + page + "&limit=" + limit;
190
+ return request("GET", "/v1/entity/" + entity + "/history/" + seq + query, null);
191
+ }
192
+
193
+ /** 트랜잭션 롤백 */
194
+ public String rollback(String entity, long historySeq) throws IOException {
195
+ return request("POST", "/v1/entity/" + entity + "/rollback/" + historySeq, null);
196
+ }
197
+
198
+ // ─── 내부 ─────────────────────────────────────────────────────────────────
199
+
200
+ private String request(String method, String path, String body) throws IOException {
201
+ return request(method, path, body, Collections.emptyMap());
202
+ }
203
+
204
+ private String request(String method, String path, String body, Map<String, String> extraHeaders) throws IOException {
205
+ String bodyStr = body != null ? body : "";
206
+ String timestamp = String.valueOf(System.currentTimeMillis() / 1000L);
207
+ String nonce = UUID.randomUUID().toString();
208
+ String signature = sign(method, path, timestamp, nonce, bodyStr);
209
+
210
+ URL url = new URL(baseUrl + path);
211
+ HttpURLConnection conn = (HttpURLConnection) url.openConnection();
212
+ conn.setRequestMethod(method);
213
+ conn.setConnectTimeout(timeoutMs);
214
+ conn.setReadTimeout(timeoutMs);
215
+ conn.setRequestProperty("Content-Type", "application/json");
216
+ conn.setRequestProperty("X-API-Key", apiKey);
217
+ conn.setRequestProperty("X-Timestamp", timestamp);
218
+ conn.setRequestProperty("X-Nonce", nonce);
219
+ conn.setRequestProperty("X-Signature", signature);
220
+ for (Map.Entry<String, String> h : extraHeaders.entrySet()) {
221
+ conn.setRequestProperty(h.getKey(), h.getValue());
222
+ }
223
+
224
+ if (!bodyStr.isEmpty()) {
225
+ conn.setDoOutput(true);
226
+ try (OutputStream os = conn.getOutputStream()) {
227
+ os.write(bodyStr.getBytes(StandardCharsets.UTF_8));
228
+ }
229
+ }
230
+
231
+ int status = conn.getResponseCode();
232
+ String contentType = conn.getContentType();
233
+ InputStream stream = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
234
+ byte[] rawBytes = readAllBytes(stream);
235
+
236
+ // 패킷 암호화 응답: application/octet-stream → 복호화
237
+ String response;
238
+ if (contentType != null && contentType.contains("application/octet-stream")) {
239
+ try {
240
+ response = decryptPacket(rawBytes);
241
+ } catch (Exception e) {
242
+ throw new IOException("Packet decryption failed: " + e.getMessage(), e);
243
+ }
244
+ } else {
245
+ response = new String(rawBytes, StandardCharsets.UTF_8);
246
+ }
247
+
248
+ if (status >= 400) {
249
+ throw new IOException("EntityServer error (HTTP " + status + "): " + response);
250
+ }
251
+ return response;
252
+ }
253
+
254
+ /**
255
+ * XChaCha20-Poly1305 패킷 복호화
256
+ * 포맷: [magic:magicLen][nonce:24][ciphertext+tag]
257
+ * 키: sha256(hmac_secret)
258
+ *
259
+ * Bouncy Castle ChaCha20Poly1305 사용 (nonce 24바이트 → 자동으로 XChaCha20 선택)
260
+ */
261
+ private String decryptPacket(byte[] data) throws Exception {
262
+ byte[] key = sha256(hmacSecret.getBytes(StandardCharsets.UTF_8));
263
+ byte[] nonce = Arrays.copyOfRange(data, magicLen, magicLen + 24);
264
+ byte[] ctext = Arrays.copyOfRange(data, magicLen + 24, data.length);
265
+
266
+ ChaCha20Poly1305 aead = new ChaCha20Poly1305();
267
+ aead.init(false, new AEADParameters(new KeyParameter(key), 128, nonce));
268
+
269
+ byte[] plaintext = new byte[aead.getOutputSize(ctext.length)];
270
+ int len = aead.processBytes(ctext, 0, ctext.length, plaintext, 0);
271
+ aead.doFinal(plaintext, len);
272
+ return new String(plaintext, StandardCharsets.UTF_8);
273
+ }
274
+
275
+ /** HMAC-SHA256 서명 */
276
+ private String sign(String method, String path, String timestamp, String nonce, String body) {
277
+ try {
278
+ String payload = String.join("|", method, path, timestamp, nonce, body);
279
+ Mac mac = Mac.getInstance("HmacSHA256");
280
+ mac.init(new SecretKeySpec(hmacSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
281
+ byte[] hash = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
282
+ return HexFormat.of().formatHex(hash); // Java 17+
283
+ } catch (Exception e) {
284
+ throw new RuntimeException("HMAC signing failed", e);
285
+ }
286
+ }
287
+
288
+ private static byte[] sha256(byte[] input) throws Exception {
289
+ return MessageDigest.getInstance("SHA-256").digest(input);
290
+ }
291
+
292
+ private static byte[] readAllBytes(InputStream in) throws IOException {
293
+ ByteArrayOutputStream buf = new ByteArrayOutputStream();
294
+ byte[] chunk = new byte[8192];
295
+ int n;
296
+ while ((n = in.read(chunk)) != -1) buf.write(chunk, 0, n);
297
+ return buf.toByteArray();
298
+ }
299
+
300
+ private static String getEnv(String key, String defaultValue) {
301
+ String v = System.getenv(key);
302
+ return (v != null && !v.isBlank()) ? v : defaultValue;
303
+ }
304
+ }
@@ -0,0 +1,49 @@
1
+ package com.example.entityserver;
2
+
3
+ /**
4
+ * EntityServerClient 사용 예제
5
+ *
6
+ * 컴파일 및 실행:
7
+ * javac EntityServerClient.java EntityServerExample.java
8
+ * ENTITY_SERVER_URL=http://localhost:47200 \
9
+ * ENTITY_SERVER_API_KEY=mykey \
10
+ * ENTITY_SERVER_HMAC_SECRET=mysecret \
11
+ * java EntityServerExample
12
+ */
13
+ public class EntityServerExample {
14
+
15
+ public static void main(String[] args) throws Exception {
16
+ EntityServerClient es = new EntityServerClient();
17
+
18
+ // 목록 조회
19
+ String list = es.list("product", 1, 20, null);
20
+ System.out.println("List: " + list);
21
+
22
+ // 생성
23
+ String created = es.submit("product", "{\"name\":\"노트북\",\"price\":1500000,\"category\":\"electronics\"}");
24
+ System.out.println("Created: " + created);
25
+
26
+ // 단건 조회
27
+ String item = es.get("product", 1);
28
+ System.out.println("Get: " + item);
29
+
30
+ // 수정 (seq 포함)
31
+ String updated = es.submit("product", "{\"seq\":1,\"name\":\"게이밍 노트북\",\"price\":2000000}");
32
+ System.out.println("Updated: " + updated);
33
+
34
+ // 필터 검색
35
+ String results = es.query("product",
36
+ "[{\"field\":\"category\",\"op\":\"eq\",\"value\":\"electronics\"}]",
37
+ 1, 10
38
+ );
39
+ System.out.println("Query: " + results);
40
+
41
+ // 이력 조회
42
+ String history = es.history("product", 1, 1, 20);
43
+ System.out.println("History: " + history);
44
+
45
+ // 삭제
46
+ String deleted = es.delete("product", 1);
47
+ System.out.println("Deleted: " + deleted);
48
+ }
49
+ }