diff --git a/frontend/.dart_tool/package_config.json b/frontend/.dart_tool/package_config.json index 84ce263..9068493 100644 --- a/frontend/.dart_tool/package_config.json +++ b/frontend/.dart_tool/package_config.json @@ -49,6 +49,18 @@ "packageUri": "lib/", "languageVersion": "3.7" }, + { + "name": "http", + "rootUri": "file:///Users/akarso/.pub-cache/hosted/pub.dev/http-0.13.6", + "packageUri": "lib/", + "languageVersion": "2.19" + }, + { + "name": "http_parser", + "rootUri": "file:///Users/akarso/.pub-cache/hosted/pub.dev/http_parser-4.1.2", + "packageUri": "lib/", + "languageVersion": "3.4" + }, { "name": "leak_tracker", "rootUri": "file:///Users/akarso/.pub-cache/hosted/pub.dev/leak_tracker-10.0.9", @@ -133,6 +145,12 @@ "packageUri": "lib/", "languageVersion": "3.5" }, + { + "name": "typed_data", + "rootUri": "file:///Users/akarso/.pub-cache/hosted/pub.dev/typed_data-1.4.0", + "packageUri": "lib/", + "languageVersion": "3.5" + }, { "name": "vector_math", "rootUri": "file:///Users/akarso/.pub-cache/hosted/pub.dev/vector_math-2.1.4", diff --git a/frontend/.dart_tool/package_config_subset b/frontend/.dart_tool/package_config_subset index 34eb532..aff914e 100644 --- a/frontend/.dart_tool/package_config_subset +++ b/frontend/.dart_tool/package_config_subset @@ -22,6 +22,14 @@ fake_async 3.3 file:///Users/akarso/.pub-cache/hosted/pub.dev/fake_async-1.3.3/ file:///Users/akarso/.pub-cache/hosted/pub.dev/fake_async-1.3.3/lib/ +http +2.19 +file:///Users/akarso/.pub-cache/hosted/pub.dev/http-0.13.6/ +file:///Users/akarso/.pub-cache/hosted/pub.dev/http-0.13.6/lib/ +http_parser +3.4 +file:///Users/akarso/.pub-cache/hosted/pub.dev/http_parser-4.1.2/ +file:///Users/akarso/.pub-cache/hosted/pub.dev/http_parser-4.1.2/lib/ leak_tracker 3.2 file:///Users/akarso/.pub-cache/hosted/pub.dev/leak_tracker-10.0.9/ @@ -74,6 +82,10 @@ test_api 3.5 file:///Users/akarso/.pub-cache/hosted/pub.dev/test_api-0.7.4/ file:///Users/akarso/.pub-cache/hosted/pub.dev/test_api-0.7.4/lib/ +typed_data +3.5 +file:///Users/akarso/.pub-cache/hosted/pub.dev/typed_data-1.4.0/ +file:///Users/akarso/.pub-cache/hosted/pub.dev/typed_data-1.4.0/lib/ vector_math 2.14 file:///Users/akarso/.pub-cache/hosted/pub.dev/vector_math-2.1.4/ diff --git a/frontend/.dart_tool/package_graph.json b/frontend/.dart_tool/package_graph.json index 0403a5a..2bdfd78 100644 --- a/frontend/.dart_tool/package_graph.json +++ b/frontend/.dart_tool/package_graph.json @@ -7,7 +7,8 @@ "name": "pano_chart_frontend", "version": "0.0.0", "dependencies": [ - "flutter" + "flutter", + "http" ], "devDependencies": [ "flutter_test" @@ -41,6 +42,15 @@ "vm_service" ] }, + { + "name": "http", + "version": "0.13.6", + "dependencies": [ + "async", + "http_parser", + "meta" + ] + }, { "name": "flutter", "version": "0.0.0", @@ -211,10 +221,27 @@ "term_glyph" ] }, + { + "name": "http_parser", + "version": "4.1.2", + "dependencies": [ + "collection", + "source_span", + "string_scanner", + "typed_data" + ] + }, { "name": "sky_engine", "version": "0.0.0", "dependencies": [] + }, + { + "name": "typed_data", + "version": "1.4.0", + "dependencies": [ + "collection" + ] } ], "configVersion": 1 diff --git a/frontend/docs/PR-004.md b/frontend/docs/PR-004.md new file mode 100644 index 0000000..f957713 --- /dev/null +++ b/frontend/docs/PR-004.md @@ -0,0 +1,167 @@ +# PR-004 — HTTP Adapter (Candle API) + +## Purpose + +Introduce the **HTTP adapter** that implements the `CandleApi` port defined in `frontend/PR-002.md` and connects the frontend application layer to the backend `/api/v1/candles` endpoint frozen in `docs/PR-011.md`. + +This PR is the **only place** where HTTP, JSON transport, and network concerns are allowed. + +--- + +## References + +* `AGENTS.md` — global collaboration rules +* `COMMON.md` — shared API semantics +* `frontend/PR-002.md` — CandleApi port & DTOs +* `frontend/PR-003.md` — GetCandleSeries frontend use case +* `docs/PR-011.md` — frozen backend API contract + +--- + +## Scope (Hard Lock) + +### ✅ Included + +* HTTP implementation of `CandleApi` +* Query parameter mapping +* JSON decoding into DTOs +* Network error translation +* Adapter-level tests + +### ❌ Excluded + +* UI widgets +* State management +* Caching +* Retry logic +* Authentication + +If it is not HTTP transport, it does not belong here. + +--- + +## Folder Structure + +``` +/frontend/lib/ + features/candles/ + infrastructure/ + http_candle_api.dart + +/frontend/test/ + features/candles/infrastructure/ +``` + +--- + +## HTTP Client Choice (Locked) + +* Use `package:http` (dart `http` client) +* No Dio + +Rationale: + +* Small surface area +* Easy to fake in tests +* No interceptors or magic + +--- + +## Adapter Responsibilities + +### HttpCandleApi + +Responsibilities: + +* Build GET request for `/api/v1/candles` +* Encode query parameters +* Decode JSON response +* Return `CandleSeriesResponse` + +Constraints: + +* Stateless +* No caching +* No retries +* No Flutter widget dependencies + +--- + +## Request Mapping Rules + +* `symbol`, `timeframe`, `from`, `to` mapped 1:1 to query parameters +* `from` and `to` serialized as RFC3339 strings +* Query parameters must be URL-encoded + +--- + +## Response Handling Rules + +* HTTP 200 → parse response body +* HTTP 400 → throw client error +* HTTP 500 → throw server error +* Unexpected status → generic failure + +Error types may be simple and internal. + +--- + +## Testing Requirements + +### Required Test Cases + +* `HttpCandleApi_buildsCorrectRequest` +* `HttpCandleApi_parsesSuccessfulResponse` +* `HttpCandleApi_handles400` +* `HttpCandleApi_handles500` + +Tests must: + +* Fake HTTP client +* Avoid real network calls + +--- + +## Implementation Constraints + +* Constructor injection of HTTP client and base URL +* No static globals +* No logging + +--- + +## Definition of Done (PR-004) + +PR-004 is considered **done** only when **all** conditions below are met: + +* [ ] `CandleApi` implemented via HTTP +* [ ] Query parameters match backend contract +* [ ] JSON decoded into DTOs +* [ ] Error cases covered by tests +* [ ] No UI or state logic present +* [ ] Static analysis passes +* [ ] PR is reviewable in under 20 minutes + +--- + +## Explicit Non-Goals + +* No request retries +* No timeout tuning +* No cancellation support + +--- + +## Reviewer Checklist + +Reviewer must confirm: + +* Adapter respects API contract exactly +* No logic leakage beyond transport +* HTTP client is properly injected + +--- + +## Guiding Principle + +Transport is a detail, not a decision. diff --git a/frontend/docs/PR-005.md b/frontend/docs/PR-005.md new file mode 100644 index 0000000..e69de29 diff --git a/frontend/lib/features/candles/infrastructure/http_candle_api.dart b/frontend/lib/features/candles/infrastructure/http_candle_api.dart new file mode 100644 index 0000000..d48229a --- /dev/null +++ b/frontend/lib/features/candles/infrastructure/http_candle_api.dart @@ -0,0 +1,56 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../api/candle_api.dart'; +import '../api/candle_request.dart'; +import '../api/candle_response.dart'; + +class HttpCandleApiException implements Exception { + final int? statusCode; + final String message; + + HttpCandleApiException(this.message, {this.statusCode}); + + @override + String toString() => + 'HttpCandleApiException(statusCode: $statusCode, message: $message)'; +} + +/// HTTP adapter implementing [CandleApi]. +class HttpCandleApi implements CandleApi { + final String baseUrl; + final http.Client _client; + + HttpCandleApi({required this.baseUrl, http.Client? client}) + : _client = client ?? http.Client(); + + @override + Future fetchCandles(CandleRequest request) async { + final uri = + Uri.parse(baseUrl).replace(path: '/api/v1/candles', queryParameters: { + 'symbol': request.symbol, + 'timeframe': request.timeframe, + 'from': request.from.toUtc().toIso8601String(), + 'to': request.to.toUtc().toIso8601String(), + }); + + final res = await _client.get(uri); + + if (res.statusCode == 200) { + final body = jsonDecode(res.body) as Map; + return CandleSeriesResponse.fromJson(body); + } + + if (res.statusCode == 400) { + throw HttpCandleApiException('Bad request', statusCode: 400); + } + + if (res.statusCode == 500) { + throw HttpCandleApiException('Server error', statusCode: 500); + } + + throw HttpCandleApiException('Unexpected status: ${res.statusCode}', + statusCode: res.statusCode); + } +} diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index e688602..c3563f9 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: flutter: sdk: flutter + http: ^0.13.0 dev_dependencies: flutter_test: diff --git a/frontend/test/features/candles/infrastructure/http_candle_api_test.dart b/frontend/test/features/candles/infrastructure/http_candle_api_test.dart new file mode 100644 index 0000000..0820086 --- /dev/null +++ b/frontend/test/features/candles/infrastructure/http_candle_api_test.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:pano_chart_frontend/features/candles/infrastructure/http_candle_api.dart'; +import 'package:pano_chart_frontend/features/candles/api/candle_request.dart'; + +class _FakeHttpClient extends http.BaseClient { + http.Request? lastRequest; + final http.Response Function(http.Request request) handler; + + _FakeHttpClient(this.handler); + + @override + Future send(http.BaseRequest request) async { + // convert BaseRequest to Request to access body and method + final req = http.Request(request.method, request.url); + lastRequest = req; + final res = handler(req); + final stream = Stream.fromIterable([res.bodyBytes]); + return http.StreamedResponse(stream, res.statusCode, + headers: res.headers, reasonPhrase: res.reasonPhrase); + } +} + +void main() { + test('HttpCandleApi_buildsCorrectRequest', () async { + http.Request? captured; + final fake = _FakeHttpClient((req) { + captured = req; + final body = jsonEncode( + {'symbol': 'BTCUSDT', 'timeframe': '1m', 'candles': []}); + return http.Response(body, 200, + headers: {'content-type': 'application/json'}); + }); + + final api = HttpCandleApi(baseUrl: 'https://api.example', client: fake); + final from = DateTime.utc(2024, 1, 1); + final to = DateTime.utc(2024, 1, 2); + final request = + CandleRequest(symbol: 'BTCUSDT', timeframe: '1m', from: from, to: to); + + await api.fetchCandles(request); + + expect(captured, isNotNull); + final uri = captured!.url; + expect(uri.path, '/api/v1/candles'); + expect(uri.queryParameters['symbol'], 'BTCUSDT'); + expect(uri.queryParameters['timeframe'], '1m'); + expect(uri.queryParameters['from'], from.toUtc().toIso8601String()); + expect(uri.queryParameters['to'], to.toUtc().toIso8601String()); + }); + + test('HttpCandleApi_parsesSuccessfulResponse', () async { + final sample = jsonEncode({ + 'symbol': 'BTCUSDT', + 'timeframe': '1m', + 'candles': [ + { + 'timestamp': '2024-01-01T00:00:00Z', + 'open': 42000.0, + 'high': 42100.0, + 'low': 41950.0, + 'close': 42050.0, + 'volume': 123.45 + } + ] + }); + + final fake = _FakeHttpClient((req) => http.Response(sample, 200, + headers: {'content-type': 'application/json'})); + final api = HttpCandleApi(baseUrl: 'https://api.example', client: fake); + final out = await api.fetchCandles(CandleRequest( + symbol: 'BTCUSDT', + timeframe: '1m', + from: DateTime.utc(2024, 1, 1), + to: DateTime.utc(2024, 1, 2))); + + expect(out.symbol, 'BTCUSDT'); + expect(out.timeframe, '1m'); + expect(out.candles.length, 1); + expect(out.candles.first.open, 42000.0); + }); + + test('HttpCandleApi_handles400', () async { + final fake = _FakeHttpClient((req) => http.Response('bad', 400)); + final api = HttpCandleApi(baseUrl: 'https://api.example', client: fake); + + expect( + () => api.fetchCandles(CandleRequest( + symbol: 'BTCUSDT', + timeframe: '1m', + from: DateTime.utc(2024, 1, 1), + to: DateTime.utc(2024, 1, 2))), + throwsA(isA())); + }); + + test('HttpCandleApi_handles500', () async { + final fake = _FakeHttpClient((req) => http.Response('err', 500)); + final api = HttpCandleApi(baseUrl: 'https://api.example', client: fake); + + expect( + () => api.fetchCandles(CandleRequest( + symbol: 'BTCUSDT', + timeframe: '1m', + from: DateTime.utc(2024, 1, 1), + to: DateTime.utc(2024, 1, 2))), + throwsA(isA())); + }); +}