Skip to content
This repository was archived by the owner on Dec 12, 2024. It is now read-only.

Commit 3587057

Browse files
authored
feat: add http client (#9)
* add `mocktail` and `http` deps * add http client models * refactor to better support http client * add `TbdexHttpClient` * add response strings for http client * refactor model tests * add more exhaustive tests for `Parser` * add tests for `TbdexHttpClient` using `mocktail` * nit: newlines * make json header variable private * remove unnecessary cast * add optional `to` to support http client testing * use `replace()` instead of reconstructing `Uri` * fix messages that need to be signed by alice * return one exchange in exchanges * fix `when()` mocks and add `verify()` assertions
1 parent 97e9268 commit 3587057

15 files changed

+714
-53
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import 'package:tbdex/src/protocol/models/rfq.dart';
2+
3+
class CreateExchangeRequest {
4+
final Rfq rfq;
5+
final String? replyTo;
6+
7+
CreateExchangeRequest({
8+
required this.rfq,
9+
this.replyTo,
10+
});
11+
12+
Map<String, dynamic> toJson() {
13+
return {
14+
'rfq': rfq.toJson(),
15+
if (replyTo != null) 'replyTo': replyTo,
16+
};
17+
}
18+
}
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import 'package:tbdex/src/protocol/models/message.dart';
2+
3+
typedef Exchange = List<Message>;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
class GetOfferingsFilter {
2+
final String? payinCurrency;
3+
final String? payoutCurrency;
4+
final String? id;
5+
6+
GetOfferingsFilter({
7+
this.payinCurrency,
8+
this.payoutCurrency,
9+
this.id,
10+
});
11+
12+
factory GetOfferingsFilter.fromJson(Map<String, dynamic> json) {
13+
return GetOfferingsFilter(
14+
payinCurrency: json['payinCurrency'],
15+
payoutCurrency: json['payoutCurrency'],
16+
id: json['id'],
17+
);
18+
}
19+
20+
Map<String, dynamic> toJson() {
21+
return {
22+
if (payinCurrency != null) 'payinCurrency': payinCurrency,
23+
if (payoutCurrency != null) 'payoutCurrency': payoutCurrency,
24+
if (id != null) 'id': id,
25+
};
26+
}
27+
}
+174
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import 'dart:convert';
2+
3+
import 'package:http/http.dart' as http;
4+
import 'package:tbdex/src/http_client/models/create_exchange_request.dart';
5+
import 'package:tbdex/src/http_client/models/exchange.dart';
6+
import 'package:tbdex/src/http_client/models/get_offerings_filter.dart';
7+
import 'package:tbdex/src/protocol/models/close.dart';
8+
import 'package:tbdex/src/protocol/models/offering.dart';
9+
import 'package:tbdex/src/protocol/models/order.dart';
10+
import 'package:tbdex/src/protocol/models/rfq.dart';
11+
import 'package:tbdex/src/protocol/parser.dart';
12+
import 'package:tbdex/src/protocol/validator.dart';
13+
import 'package:typeid/typeid.dart';
14+
import 'package:web5/web5.dart';
15+
16+
class TbdexHttpClient {
17+
static const _jsonHeader = 'application/json';
18+
static const _expirationDuration = Duration(minutes: 5);
19+
20+
static http.Client _client = http.Client();
21+
22+
// ignore: avoid_setters_without_getters
23+
static set client(http.Client client) {
24+
_client = client;
25+
}
26+
27+
static Future<Exchange> getExchange(
28+
BearerDid did,
29+
String pfiDid,
30+
String exchangeId,
31+
) async {
32+
final requestToken = await _generateRequestToken(did, pfiDid);
33+
final pfiServiceEndpoint = await _getPfiServiceEndpoint(pfiDid);
34+
final url = Uri.parse('$pfiServiceEndpoint/exchanges/$exchangeId');
35+
36+
final response = await _client.get(
37+
url,
38+
headers: {
39+
'Authorization': 'Bearer $requestToken',
40+
},
41+
);
42+
43+
if (response.statusCode != 200) {
44+
throw Exception('failed to fetch exchange: ${response.body}');
45+
}
46+
47+
return Parser.parseExchange(response.body);
48+
}
49+
50+
static Future<List<Exchange>> getExchanges(
51+
BearerDid did,
52+
String pfiDid,
53+
) async {
54+
final requestToken = await _generateRequestToken(did, pfiDid);
55+
final pfiServiceEndpoint = await _getPfiServiceEndpoint(pfiDid);
56+
final url = Uri.parse('$pfiServiceEndpoint/exchanges/');
57+
58+
final response = await _client.get(
59+
url,
60+
headers: {
61+
'Authorization': 'Bearer $requestToken',
62+
},
63+
);
64+
65+
if (response.statusCode != 200) {
66+
throw Exception('failed to fetch exchanges: ${response.body}');
67+
}
68+
69+
return Parser.parseExchanges(response.body);
70+
}
71+
72+
static Future<List<Offering>> getOfferings(
73+
String pfiDid, {
74+
GetOfferingsFilter? filter,
75+
}) async {
76+
final pfiServiceEndpoint = await _getPfiServiceEndpoint(pfiDid);
77+
final url = Uri.parse('$pfiServiceEndpoint/offerings/').replace(
78+
queryParameters: filter?.toJson(),
79+
);
80+
81+
final response = await _client.get(url);
82+
83+
if (response.statusCode != 200) {
84+
throw Exception(response);
85+
}
86+
87+
return Parser.parseOfferings(response.body);
88+
}
89+
90+
static Future<void> createExchange(
91+
Rfq rfq, {
92+
String? replyTo,
93+
}) async {
94+
Validator.validateMessage(rfq);
95+
final pfiDid = rfq.metadata.to;
96+
final body = jsonEncode(
97+
CreateExchangeRequest(rfq: rfq, replyTo: replyTo),
98+
);
99+
100+
await _submitMessage(pfiDid, body);
101+
}
102+
103+
static Future<void> submitOrder(Order order) async {
104+
Validator.validateMessage(order);
105+
final pfiDid = order.metadata.to;
106+
final exchangeId = order.metadata.exchangeId;
107+
final body = jsonEncode(order.toJson());
108+
109+
await _submitMessage(pfiDid, body, exchangeId: exchangeId);
110+
}
111+
112+
static Future<void> submitClose(Close close) async {
113+
Validator.validateMessage(close);
114+
final pfiDid = close.metadata.to;
115+
final exchangeId = close.metadata.exchangeId;
116+
final body = jsonEncode(close.toJson());
117+
118+
await _submitMessage(pfiDid, body, exchangeId: exchangeId);
119+
}
120+
121+
static Future<void> _submitMessage(
122+
String pfiDid,
123+
String requestBody, {
124+
String? exchangeId,
125+
}) async {
126+
final pfiServiceEndpoint = await _getPfiServiceEndpoint(pfiDid);
127+
final path = '/exchanges${exchangeId != null ? '/$exchangeId' : ''}';
128+
final url = Uri.parse(pfiServiceEndpoint + path);
129+
130+
final response = await _client.post(
131+
url,
132+
headers: {'Content-Type': _jsonHeader},
133+
body: requestBody,
134+
);
135+
136+
if (response.statusCode != 201) {
137+
throw Exception(response);
138+
}
139+
}
140+
141+
static Future<String> _getPfiServiceEndpoint(String pfiDid) async {
142+
final didResolutionResult = await DidResolver.resolve(pfiDid);
143+
144+
if (didResolutionResult.didDocument == null) {
145+
throw Exception('did resolution failed');
146+
}
147+
148+
final service = didResolutionResult.didDocument?.service?.firstWhere(
149+
(service) => service.type == 'PFI',
150+
orElse: () => throw Exception('did does not have service of type PFI'),
151+
);
152+
153+
return service?.serviceEndpoint ?? '';
154+
}
155+
156+
static Future<String> _generateRequestToken(
157+
BearerDid did,
158+
String pfiDid,
159+
) async {
160+
final nowEpochSeconds = DateTime.now().millisecondsSinceEpoch ~/ 1000;
161+
final exp = nowEpochSeconds + _expirationDuration.inSeconds;
162+
163+
return Jwt.sign(
164+
did: did,
165+
payload: JwtClaims(
166+
aud: pfiDid,
167+
iss: did.uri,
168+
exp: exp,
169+
iat: nowEpochSeconds,
170+
jti: TypeId.generate(''),
171+
),
172+
);
173+
}
174+
}

lib/src/protocol/parser.dart

+110-17
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'dart:convert';
22

3+
import 'package:tbdex/src/http_client/models/exchange.dart';
34
import 'package:tbdex/src/protocol/models/close.dart';
45
import 'package:tbdex/src/protocol/models/message.dart';
56
import 'package:tbdex/src/protocol/models/offering.dart';
@@ -11,7 +12,97 @@ import 'package:tbdex/src/protocol/models/rfq.dart';
1112

1213
abstract class Parser {
1314
static Message parseMessage(String rawMessage) {
14-
final jsonObject = jsonDecode(rawMessage) as Map<String, dynamic>?;
15+
final jsonObject = jsonDecode(rawMessage);
16+
17+
if (jsonObject is! Map<String, dynamic>) {
18+
throw Exception('message must be a json object');
19+
}
20+
21+
return _parseMessageJson(jsonObject);
22+
}
23+
24+
static Resource parseResource(String rawResource) {
25+
final jsonObject = jsonDecode(rawResource);
26+
27+
if (jsonObject is! Map<String, dynamic>) {
28+
throw Exception('resource must be a json object');
29+
}
30+
31+
return _parseResourceJson(jsonObject);
32+
}
33+
34+
static Exchange parseExchange(String rawExchange) {
35+
final jsonObject = jsonDecode(rawExchange);
36+
37+
if (jsonObject is! Map<String, dynamic>) {
38+
throw Exception('exchange must be a json object');
39+
}
40+
41+
final exchange = jsonObject['data'];
42+
43+
if (exchange is! List<dynamic> || exchange.isEmpty) {
44+
throw Exception('exchange data is malformed or empty');
45+
}
46+
47+
final parsedMessages = <Message>[];
48+
for (final messageJson in exchange) {
49+
final message = _parseMessageJson(messageJson);
50+
parsedMessages.add(message);
51+
}
52+
53+
return parsedMessages;
54+
}
55+
56+
static List<Exchange> parseExchanges(String rawExchanges) {
57+
final jsonObject = jsonDecode(rawExchanges);
58+
59+
if (jsonObject is! Map<String, dynamic>) {
60+
throw Exception('exchanges must be a json object');
61+
}
62+
63+
final exchanges = jsonObject['data'];
64+
65+
if (exchanges is! List<dynamic> || exchanges.isEmpty) {
66+
throw Exception('exchanges data is malformed or empty');
67+
}
68+
69+
final parsedExchanges = <Exchange>[];
70+
71+
for (final exchangeJson in exchanges) {
72+
final parsedMessages = <Message>[];
73+
for (final messageJson in exchangeJson) {
74+
final message = _parseMessageJson(messageJson);
75+
parsedMessages.add(message);
76+
}
77+
parsedExchanges.add(parsedMessages);
78+
}
79+
80+
return parsedExchanges;
81+
}
82+
83+
static List<Offering> parseOfferings(String rawOfferings) {
84+
final jsonObject = jsonDecode(rawOfferings);
85+
86+
if (jsonObject is! Map<String, dynamic>) {
87+
throw Exception('offerings must be a json object');
88+
}
89+
90+
final offerings = jsonObject['data'];
91+
92+
if (offerings is! List<dynamic> || offerings.isEmpty) {
93+
throw Exception('offerings data is malformed or empty');
94+
}
95+
96+
final parsedOfferings = <Offering>[];
97+
for (final offeringJson in offerings) {
98+
final offering = _parseResourceJson(offeringJson) as Offering;
99+
parsedOfferings.add(offering);
100+
}
101+
102+
return parsedOfferings;
103+
}
104+
105+
static Message _parseMessageJson(Map<String, dynamic> jsonObject) {
15106
final messageKind = _getKindFromJson(jsonObject);
16107
final matchedKind = MessageKind.values.firstWhere(
17108
(kind) => kind.name == messageKind,
@@ -20,20 +111,19 @@ abstract class Parser {
20111

21112
switch (matchedKind) {
22113
case MessageKind.rfq:
23-
return Rfq.fromJson(jsonObject ?? {});
114+
return Rfq.fromJson(jsonObject);
24115
case MessageKind.quote:
25-
return Quote.fromJson(jsonObject ?? {});
116+
return Quote.fromJson(jsonObject);
26117
case MessageKind.close:
27-
return Close.fromJson(jsonObject ?? {});
118+
return Close.fromJson(jsonObject);
28119
case MessageKind.order:
29-
return Order.fromJson(jsonObject ?? {});
120+
return Order.fromJson(jsonObject);
30121
case MessageKind.orderstatus:
31-
return OrderStatus.fromJson(jsonObject ?? {});
122+
return OrderStatus.fromJson(jsonObject);
32123
}
33124
}
34125

35-
static Resource parseResource(String rawResource) {
36-
final jsonObject = jsonDecode(rawResource) as Map<String, dynamic>?;
126+
static Resource _parseResourceJson(Map<String, dynamic> jsonObject) {
37127
final resourceKind = _getKindFromJson(jsonObject);
38128
final matchedKind = ResourceKind.values.firstWhere(
39129
(kind) => kind.name == resourceKind,
@@ -42,25 +132,28 @@ abstract class Parser {
42132

43133
switch (matchedKind) {
44134
case ResourceKind.offering:
45-
return Offering.fromJson(jsonObject ?? {});
135+
return Offering.fromJson(jsonObject);
46136
case ResourceKind.balance:
47137
case ResourceKind.reputation:
48138
throw UnimplementedError();
49139
}
50140
}
51141

52-
static String _getKindFromJson(Map<String, dynamic>? jsonObject) {
53-
if (jsonObject == null) {
54-
throw Exception('string is not a valid json object');
142+
static String _getKindFromJson(Map<String, dynamic> jsonObject) {
143+
final metadata = jsonObject['metadata'];
144+
145+
if (metadata is! Map<String, dynamic> || metadata.isEmpty) {
146+
throw Exception('metadata is malformed or empty');
55147
}
56148

57-
final metadata = jsonObject['metadata'] as Map<String, dynamic>?;
149+
final kind = metadata['kind'];
58150

59-
if (metadata == null) {
60-
throw Exception('metadata property is required');
151+
if (kind is! String) {
152+
throw Exception(
153+
'kind property is required in metadata and must be a string',
154+
);
61155
}
62156

63-
final kind = metadata['kind'] as String?;
64-
return kind ?? (throw Exception('kind property is required in metadata'));
157+
return kind;
65158
}
66159
}

0 commit comments

Comments
 (0)