Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add api client package #5

Merged
merged 4 commits into from
Feb 27, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/api_client.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: api_client

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

on:
pull_request:
paths:
- "packages/api_client/**"
- ".github/workflows/api_client.yaml"
branches:
- main

jobs:
build:
uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1
with:
dart_sdk: stable
working_directory: packages/api_client
7 changes: 7 additions & 0 deletions packages/api_client/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# See https://www.dartlang.org/guides/libraries/private-files

# Files and directories created by pub
.dart_tool/
.packages
build/
pubspec.lock
62 changes: 62 additions & 0 deletions packages/api_client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Api Client

[![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link]
[![Powered by Mason](https://img.shields.io/endpoint?url=https%3A%2F%2Ftinyurl.com%2Fmason-badge)](https://github.com/felangel/mason)
[![License: MIT][license_badge]][license_link]

Client to access the api.

## Installation 💻

**❗ In order to start using Api Client you must have the [Dart SDK][dart_install_link] installed on your machine.**

Install via `dart pub add`:

```sh
dart pub add api_client
```

---

## Continuous Integration 🤖

Api Client comes with a built-in [GitHub Actions workflow][github_actions_link] powered by [Very Good Workflows][very_good_workflows_link] but you can also add your preferred CI/CD solution.

Out of the box, on each pull request and push, the CI `formats`, `lints`, and `tests` the code. This ensures the code remains consistent and behaves correctly as you add functionality or make changes. The project uses [Very Good Analysis][very_good_analysis_link] for a strict set of analysis options used by our team. Code coverage is enforced using the [Very Good Workflows][very_good_coverage_link].

---

## Running Tests 🧪

To run all unit tests:

```sh
dart pub global activate coverage 1.2.0
dart test --coverage=coverage
dart pub global run coverage:format_coverage --lcov --in=coverage --out=coverage/lcov.info
```

To view the generated coverage report you can use [lcov](https://github.com/linux-test-project/lcov).

```sh
# Generate Coverage Report
genhtml coverage/lcov.info -o coverage/

# Open Coverage Report
open coverage/index.html
```

[dart_install_link]: https://dart.dev/get-dart
[github_actions_link]: https://docs.github.com/en/actions/learn-github-actions
[license_badge]: https://img.shields.io/badge/license-MIT-blue.svg
[license_link]: https://opensource.org/licenses/MIT
[logo_black]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_black.png#gh-light-mode-only
[logo_white]: https://raw.githubusercontent.com/VGVentures/very_good_brand/main/styles/README/vgv_logo_white.png#gh-dark-mode-only
[mason_link]: https://github.com/felangel/mason
[very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg
[very_good_analysis_link]: https://pub.dev/packages/very_good_analysis
[very_good_coverage_link]: https://github.com/marketplace/actions/very-good-coverage
[very_good_ventures_link]: https://verygood.ventures
[very_good_ventures_link_light]: https://verygood.ventures#gh-light-mode-only
[very_good_ventures_link_dark]: https://verygood.ventures#gh-dark-mode-only
[very_good_workflows_link]: https://github.com/VeryGoodOpenSource/very_good_workflows
1 change: 1 addition & 0 deletions packages/api_client/analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include: package:very_good_analysis/analysis_options.5.1.0.yaml
20 changes: 20 additions & 0 deletions packages/api_client/coverage_badge.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions packages/api_client/lib/api_client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/// A Very Good Project created by Very Good CLI.
B0berman marked this conversation as resolved.
Show resolved Hide resolved
library api_client;

export 'src/api_client.dart';
261 changes: 261 additions & 0 deletions packages/api_client/lib/src/api_client.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,261 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:encrypt/encrypt.dart';
import 'package:http/http.dart' as http;

/// {@template api_client_error}
/// Error throw when accessing api failed.
///
/// Check [cause] and [stackTrace] for specific details.
/// {@endtemplate}
class ApiClientError implements Exception {
/// {@macro api_client_error}
ApiClientError(this.cause, this.stackTrace);

/// Error cause.
final dynamic cause;

/// The stack trace of the error.
final StackTrace stackTrace;

@override
String toString() {
return cause.toString();
}
}

/// Definition of a post call used by this client.
typedef PostCall = Future<http.Response> Function(
Uri, {
Object? body,
Map<String, String>? headers,
});

/// Definition of a patch call used by this client.
typedef PatchCall = Future<http.Response> Function(
Uri, {
Object? body,
Map<String, String>? headers,
});

/// Definition of a put call used by this client.
typedef PutCall = Future<http.Response> Function(
Uri, {
Object? body,
Map<String, String>? headers,
});

/// Definition of a get call used by this client.
typedef GetCall = Future<http.Response> Function(
Uri, {
Map<String, String>? headers,
});
B0berman marked this conversation as resolved.
Show resolved Hide resolved

/// {@template api_client}
/// A Very Good Project created by Very Good CLI.
B0berman marked this conversation as resolved.
Show resolved Hide resolved
/// {@endtemplate}
class ApiClient {
/// {@macro api_client}
ApiClient({
required String baseUrl,
required Stream<String?> idTokenStream,
required Future<String?> Function() refreshIdToken,
required Stream<String?> appCheckTokenStream,
String? appCheckToken,
PostCall postCall = http.post,
PutCall putCall = http.put,
PatchCall patchCall = http.patch,
GetCall getCall = http.get,
}) : _base = Uri.parse(baseUrl),
_post = postCall,
_put = putCall,
_patch = patchCall,
_get = getCall,
_appCheckToken = appCheckToken,
_refreshIdToken = refreshIdToken {
_idTokenSubscription = idTokenStream.listen((idToken) {
_idToken = idToken;
});
_appCheckTokenSubscription = appCheckTokenStream.listen((appCheckToken) {
_appCheckToken = appCheckToken;
});
}

final Uri _base;
final PostCall _post;
final PostCall _put;
final PatchCall _patch;
final GetCall _get;
final Future<String?> Function() _refreshIdToken;

late final StreamSubscription<String?> _idTokenSubscription;
late final StreamSubscription<String?> _appCheckTokenSubscription;
String? _idToken;
String? _appCheckToken;

Map<String, String> get _headers => {
if (_idToken != null) 'Authorization': 'Bearer $_idToken',
if (_appCheckToken != null) 'X-Firebase-AppCheck': _appCheckToken!,
};

Future<http.Response> _handleUnauthorized(
Future<http.Response> Function() sendRequest,
) async {
final response = await sendRequest();

if (response.statusCode == HttpStatus.unauthorized) {
_idToken = await _refreshIdToken();
return sendRequest();
}
return response;
}

/// Dispose of resources used by this client.
Future<void> dispose() async {
await _idTokenSubscription.cancel();
await _appCheckTokenSubscription.cancel();
}

/// Sends a POST request to the specified [path] with the given [body].
Future<http.Response> post(
String path, {
Object? body,
Map<String, String>? queryParameters,
}) async {
return _handleUnauthorized(() async {
final response = await _post(
_base.replace(
path: path,
queryParameters: queryParameters,
),
body: body,
headers: _headers..addContentTypeJson(),
);

return response.decrypted;
});
}

/// Sends a PATCH request to the specified [path] with the given [body].
Future<http.Response> patch(
String path, {
Object? body,
Map<String, String>? queryParameters,
}) async {
return _handleUnauthorized(() async {
final response = await _patch(
_base.replace(
path: path,
queryParameters: queryParameters,
),
body: body,
headers: _headers..addContentTypeJson(),
);

return response.decrypted;
});
}

/// Sends a PUT request to the specified [path] with the given [body].
Future<http.Response> put(
String path, {
Object? body,
}) async {
return _handleUnauthorized(() async {
final response = await _put(
_base.replace(path: path),
body: body,
headers: _headers..addContentTypeJson(),
);

return response.decrypted;
});
}

/// Sends a GET request to the specified [path].
Future<http.Response> get(
String path, {
Map<String, String>? queryParameters,
}) async {
return _handleUnauthorized(() async {
final response = await _get(
_base.replace(
path: path,
queryParameters: queryParameters,
),
headers: _headers,
);

return response.decrypted;
});
}

/// Sends a GET request to the specified public [path].
Future<http.Response> getPublic(
String path, {
Map<String, String>? queryParameters,
}) async {
return _handleUnauthorized(() async {
final response = await _get(
_base.replace(
path: path,
queryParameters: queryParameters,
),
headers: _headers,
);

return response;
});
}
}

extension on http.Response {
http.Response get decrypted {
if (body.isEmpty) return this;

final key = Key.fromUtf8(_encryptionKey);
final iv = IV.fromUtf8(_encryptionIV);

final encrypter = Encrypter(AES(key));

final decrypted = encrypter.decrypt64(body, iv: iv);

return http.Response(
jsonDecode(decrypted).toString(),
statusCode,
headers: headers,
isRedirect: isRedirect,
persistentConnection: persistentConnection,
reasonPhrase: reasonPhrase,
request: request,
);
}

String get _encryptionKey {
const value = String.fromEnvironment(
'ENCRYPTION_KEY',
// Default value is set at 32 characters to match required length of
// AES key. The default value can then be used for testing purposes.
defaultValue: 'encryption_key_not_set_123456789',
);
return value;
}

String get _encryptionIV {
const value = String.fromEnvironment(
'ENCRYPTION_IV',
// Default value is set at 116 characters to match required length of
// IV key. The default value can then be used for testing purposes.
defaultValue: 'iv_not_set_12345',
);
return value;
}
}

extension on Map<String, String> {
void addContentTypeJson() {
addAll({HttpHeaders.contentTypeHeader: ContentType.json.value});
}
}
B0berman marked this conversation as resolved.
Show resolved Hide resolved
Loading
Loading