feat(shorebird_code_push_api): endpoints and deployment (#2)

This commit is contained in:
Felix Angelov
2023-03-02 21:00:50 -06:00
committed by GitHub
parent 2d8593c87c
commit 5b9a5cda8d
29 changed files with 590 additions and 26 deletions
@@ -20,6 +20,11 @@ on:
- "packages/shorebird_code_push_api/test/**"
- "packages/shorebird_code_push_api/pubspec.yaml"
env:
PROJECT_ID: shorebird-code-push-api
SERVICE: shorebird-code-push-api
REGION: us-central1
jobs:
semantic-pull-request:
uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/semantic_pull_request.yml@v1
@@ -33,3 +38,46 @@ jobs:
uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/pana.yml@v1
with:
working_directory: packages/shorebird_code_push_api
deploy:
needs: build
defaults:
run:
working-directory: packages/shorebird_code_push_api
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
steps:
- uses: actions/checkout@v2
- name: Setup Cloud SDK
uses: google-github-actions/setup-gcloud@v0.2.0
with:
project_id: ${{ env.PROJECT_ID }}
service_account_key: ${{ secrets.CLOUD_RUN_SA }}
export_default_credentials: true
- name: Authorize Docker Push
run: gcloud auth configure-docker
- name: Build and Push Container
run: |-
docker build -t gcr.io/${{ env.PROJECT_ID }}/${{ env.SERVICE }}:${{ github.sha }} .
docker push gcr.io/${{ env.PROJECT_ID }}/${{ env.SERVICE }}:${{ github.sha }}
- name: Deploy to Cloud Run
id: deploy
uses: google-github-actions/deploy-cloudrun@v0.4.0
with:
service: ${{ env.SERVICE }}
image: gcr.io/${{ env.PROJECT_ID }}/${{ env.SERVICE }}:${{ github.sha }}
region: ${{ env.REGION }}
- name: Show Output
run: echo ${{ steps.deploy.outputs.url }}
- name: Ping
run: curl "${{ steps.deploy.outputs.url }}"
+3 -1
View File
@@ -4,4 +4,6 @@
.dart_tool/
.packages
build/
pubspec.lock
pubspec.lock
coverage/
cache/
@@ -1 +1,4 @@
include: package:very_good_analysis/analysis_options.4.0.0.yaml
linter:
rules:
public_member_api_docs: false
@@ -3,15 +3,24 @@ import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import 'package:shelf_router/shelf_router.dart' as shelf_router;
import 'package:shorebird_code_push_api/src/middleware/middleware.dart';
import 'package:shorebird_code_push_api/src/routes/routes.dart';
Future<void> main() async {
final port = int.parse(Platform.environment['PORT'] ?? '8080');
final router = shelf_router.Router()
..all('/', (_) => Response.ok('Hello, world!'));
..all('/', (_) => Response(HttpStatus.noContent))
..post('/api/v1/updates', checkForUpdatesHandler)
..get('/api/v1/releases/<version>', downloadReleaseHandler)
..post('/api/v1/releases', uploadReleaseHandler);
final handler = const Pipeline()
.addMiddleware(versionStoreProvider)
.addHandler(router.call);
final server = await shelf_io.serve(
logRequests().addHandler(router.call),
InternetAddress.anyIPv6,
logRequests().addHandler(handler),
InternetAddress.anyIPv4,
port,
);
@@ -1,4 +0,0 @@
/// The Shorebird CodePush API
library shorebird_code_push_api;
export 'src/shorebird_code_push_api.dart';
@@ -0,0 +1 @@
const cachePath = 'cache';
@@ -0,0 +1 @@
export 'version_store_provider.dart';
@@ -0,0 +1,7 @@
import 'package:shorebird_code_push_api/src/config.dart' as config;
import 'package:shorebird_code_push_api/src/provider.dart';
import 'package:shorebird_code_push_api/src/version_store.dart';
const _versionStore = VersionStore(cachePath: config.cachePath);
final versionStoreProvider = provider<VersionStore>((_) => _versionStore);
@@ -0,0 +1,25 @@
import 'package:shelf/shelf.dart';
extension ProvideExtension on Request {
Request provide<T extends Object>(T Function() create) {
return change(context: {...context, '$T': create});
}
T lookup<T>() {
final value = context['$T'];
if (value == null) {
throw StateError(
'''
request.lookup<$T>() called with a request request that does not contain a $T.
''',
);
}
return (value as T Function())();
}
}
Middleware provider<T extends Object>(T Function(Request request) create) {
return (handler) {
return (req) => handler(req.provide(() => create(req)));
};
}
@@ -0,0 +1,2 @@
export 'check_for_updates_handler.dart';
export 'models/models.dart';
@@ -0,0 +1,39 @@
import 'dart:convert';
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shorebird_code_push_api/src/provider.dart';
import 'package:shorebird_code_push_api/src/routes/check_for_updates/check_for_updates.dart';
import 'package:shorebird_code_push_api/src/version_store.dart';
Future<Response> checkForUpdatesHandler(Request request) async {
late final CheckForUpdatesRequest checkForUpdatesRequest;
try {
checkForUpdatesRequest = CheckForUpdatesRequest.fromJson(
jsonDecode(await request.readAsString()) as Map<String, dynamic>,
);
} catch (_) {
return Response.badRequest(
body: 'Invalid request body',
headers: {HttpHeaders.contentTypeHeader: ContentType.text.value},
);
}
final store = request.lookup<VersionStore>();
final latestVersion = store.latestVersionForClient(
checkForUpdatesRequest.clientId,
currentVersion: checkForUpdatesRequest.version,
);
final response = latestVersion == null
? const CheckForUpdatesResponse()
: CheckForUpdatesResponse(
updateAvailable: true,
update: Update(version: latestVersion, hash: ''),
);
return Response.ok(
json.encode(response.toJson()),
headers: {HttpHeaders.contentTypeHeader: ContentType.json.value},
);
}
@@ -0,0 +1,31 @@
class CheckForUpdatesRequest {
const CheckForUpdatesRequest({
required this.version,
required this.platform,
required this.arch,
required this.clientId,
});
factory CheckForUpdatesRequest.fromJson(Map<String, dynamic> json) {
return CheckForUpdatesRequest(
version: (json['version'] ?? '') as String,
platform: json['platform'] as String,
arch: json['arch'] as String,
clientId: json['client_id'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'version': version,
'platform': platform,
'arch': arch,
'client_id': clientId,
};
}
final String version;
final String platform;
final String arch;
final String clientId;
}
@@ -0,0 +1,35 @@
class CheckForUpdatesResponse {
const CheckForUpdatesResponse({
this.updateAvailable = false,
this.update,
});
final bool updateAvailable;
final Update? update;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'update_available': updateAvailable,
if (update != null) 'update': update!.toJson(),
};
}
}
class Update {
const Update({required this.version, required this.hash});
final String version;
final String hash;
Map<String, dynamic> toJson() {
return <String, dynamic>{
'version': version,
'hash': hash,
'download_url': downloadUrlForVersion(version),
};
}
String downloadUrlForVersion(String version) {
return 'http://localhost:8080/releases/$version.txt';
}
}
@@ -0,0 +1,2 @@
export 'check_for_updates_request.dart';
export 'check_for_updates_response.dart';
@@ -0,0 +1 @@
export 'download_release_handler.dart';
@@ -0,0 +1,19 @@
import 'dart:io';
import 'package:path/path.dart' as path;
import 'package:shelf/shelf.dart';
import 'package:shorebird_code_push_api/src/provider.dart';
import 'package:shorebird_code_push_api/src/version_store.dart';
Future<Response> downloadReleaseHandler(
Request request,
String versionWithExtension,
) async {
final version = path.withoutExtension(versionWithExtension);
final releasePath =
request.lookup<VersionStore>().filePathForVersion(version);
final file = File(releasePath);
if (!file.existsSync()) return Response.notFound('Release not found');
final bytes = file.openRead();
return Response.ok(bytes);
}
@@ -0,0 +1,3 @@
export 'check_for_updates/check_for_updates.dart';
export 'download_release/download_release.dart';
export 'upload_release/upload_release.dart';
@@ -0,0 +1 @@
export 'upload_release_handler.dart';
@@ -0,0 +1,45 @@
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shelf_multipart/form_data.dart';
import 'package:shelf_multipart/multipart.dart';
import 'package:shorebird_code_push_api/src/provider.dart';
import 'package:shorebird_code_push_api/src/version_store.dart';
Future<Response> uploadReleaseHandler(Request request) async {
if (!request.isMultipart || !request.isMultipartForm) {
return Response.badRequest(body: 'Expected multipart form request');
}
final store = request.lookup<VersionStore>();
store.cacheDir.createSync(recursive: true);
final nextVersion = store.getNextVersion();
final path = store.filePathForVersion(nextVersion);
var foundFile = false;
try {
await for (final formData in request.multipartFormData) {
// 'file' is just the name of the field we used in this form.
if (formData.name == 'file') {
if (foundFile) {
throw Exception('Unexpected form data: ${formData.name}');
}
final file = File(path);
await file.create();
await file.writeAsBytes(await formData.part.readBytes(), flush: true);
foundFile = true;
continue;
} else {
throw Exception('Unexpected form data: ${formData.name}');
}
}
if (!foundFile) throw Exception('Missing file');
} catch (error) {
return Response.badRequest(body: error.toString());
}
return Response(HttpStatus.created, body: 'OK');
}
@@ -1,7 +0,0 @@
/// {@template shorebird_code_push_api}
/// The Shorebird CodePush API
/// {@endtemplate}
class ShorebirdCodePushApi {
/// {@macro shorebird_code_push_api}
const ShorebirdCodePushApi();
}
@@ -0,0 +1,56 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:version/version.dart';
int _compareVersions(String a, String b) {
return Version.parse(a).compareTo(Version.parse(b));
}
class VersionStore {
const VersionStore({required this.cachePath});
final String cachePath;
Directory get cacheDir {
return Directory(p.join(Directory.current.path, cachePath));
}
// Should take an api key/product name, etc.
String getNextVersion() {
final latest = latestVersionForClient('client') ?? '0.0.0';
final next = Version.parse(latest).incrementPatch().toString();
return next;
}
void addVersion(String version, List<int> bytes) {
cacheDir.createSync(recursive: true);
final path = filePathForVersion(version);
File(path).writeAsBytesSync(bytes);
}
String? latestVersionForClient(String clientId, {String? currentVersion}) {
final versions = _versionsForClientId(clientId).toList()
..sort(_compareVersions);
if (versions.isEmpty) return null;
if (versions.last == currentVersion) return null;
return versions.last;
}
String filePathForVersion(String version) {
return p.join(cacheDir.path, '$version.txt');
}
Iterable<String> _versionsForClientId(String clientId) {
// This should use the clientId to get a productId and look up the versions
// based on productId/architecture, etc.
try {
final dir = cacheDir;
final files = dir.listSync();
return files.map((e) => p.basenameWithoutExtension(e.path));
} catch (e) {
return [];
}
}
}
@@ -7,8 +7,11 @@ environment:
sdk: ">=2.19.0 <3.0.0"
dependencies:
path: ^1.8.3
shelf: ^1.4.0
shelf_multipart: ^1.0.0
shelf_router: ^1.1.3
version: ^3.0.2
dev_dependencies:
mocktail: ^0.3.0
@@ -0,0 +1,24 @@
import 'package:shelf/shelf.dart';
import 'package:shorebird_code_push_api/src/middleware/middleware.dart';
import 'package:shorebird_code_push_api/src/provider.dart';
import 'package:shorebird_code_push_api/src/version_store.dart';
import 'package:test/test.dart';
void main() {
group('versionStoreProvider', () {
test('provides a version store instance', () async {
VersionStore? store;
final handler = versionStoreProvider(
(req) {
store = req.lookup<VersionStore>();
return Response.ok('');
},
);
final request = Request('GET', Uri.parse('http://localhost/'));
await handler(request);
expect(store, isNotNull);
});
});
}
@@ -0,0 +1,45 @@
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shorebird_code_push_api/src/provider.dart';
import 'package:test/test.dart';
void main() {
test('values can be provided and read via middleware', () async {
const value = '__test_value__';
Handler middleware(Handler handler) {
return (request) {
return handler(request.provide(() => value));
};
}
Response onRequest(Request request) {
final value = request.lookup<String>();
return Response.ok(value);
}
final handler =
const Pipeline().addMiddleware(middleware).addHandler(onRequest);
final request = Request('GET', Uri.parse('http://localhost/'));
final response = await handler(request);
await expectLater(response.statusCode, equals(HttpStatus.ok));
await expectLater(await response.readAsString(), equals(value));
});
test('A StateError is thrown when reading an un-provided value', () async {
Response onRequest(Request request) {
request.lookup<Uri>();
return Response.ok('');
}
final handler = const Pipeline()
.addMiddleware((handler) => handler)
.addHandler(onRequest);
final request = Request('GET', Uri.parse('http://localhost/'));
await expectLater(() => handler(request), throwsStateError);
});
}
@@ -0,0 +1,95 @@
import 'dart:convert';
import 'dart:io';
import 'package:mocktail/mocktail.dart';
import 'package:shelf/shelf.dart';
import 'package:shorebird_code_push_api/src/provider.dart';
import 'package:shorebird_code_push_api/src/routes/check_for_updates/check_for_updates.dart';
import 'package:shorebird_code_push_api/src/version_store.dart';
import 'package:test/test.dart';
class _MockVersionStore extends Mock implements VersionStore {}
void main() {
group('checkForUpdatesHandler', () {
final uri = Uri.parse('http://localhost/');
late VersionStore store;
setUp(() {
store = _MockVersionStore();
});
test('returns 400 if request body is invalid', () async {
final request = Request('POST', uri);
final response = await checkForUpdatesHandler(request);
expect(response.statusCode, HttpStatus.badRequest);
});
test(
'returns 200 no update available '
'when unable to get latest version', () async {
const payload = CheckForUpdatesRequest(
version: '1.0.0',
platform: 'android',
arch: 'arm64',
clientId: 'client-id',
);
when(
() => store.latestVersionForClient(
any(),
currentVersion: any(named: 'currentVersion'),
),
).thenReturn(null);
final request = Request(
'POST',
uri,
body: json.encode(payload.toJson()),
).provide(() => store);
final response = await checkForUpdatesHandler(request);
expect(response.statusCode, HttpStatus.ok);
final body = await response.readAsString();
expect(body, equals('{"update_available":false}'));
});
test('returns 200 update available when version is not latest', () async {
const payload = CheckForUpdatesRequest(
version: '1.0.0',
platform: 'android',
arch: 'arm64',
clientId: 'client-id',
);
when(
() => store.latestVersionForClient(
any(),
currentVersion: any(named: 'currentVersion'),
),
).thenReturn('1.0.1');
final request = Request(
'POST',
uri,
body: json.encode(payload.toJson()),
).provide(() => store);
final response = await checkForUpdatesHandler(request);
expect(response.statusCode, HttpStatus.ok);
final body = await response.readAsString();
expect(
body,
equals(
'{"update_available":true,"update":{"version":"1.0.1","hash":"","download_url":"http://localhost:8080/releases/1.0.1.txt"}}',
),
);
});
});
}
@@ -0,0 +1,42 @@
import 'dart:io';
import 'package:mocktail/mocktail.dart';
import 'package:path/path.dart' as path;
import 'package:shelf/shelf.dart';
import 'package:shorebird_code_push_api/src/provider.dart';
import 'package:shorebird_code_push_api/src/routes/download_release/download_release.dart';
import 'package:shorebird_code_push_api/src/version_store.dart';
import 'package:test/test.dart';
class _MockVersionStore extends Mock implements VersionStore {}
void main() {
group('downloadReleaseHandler', () {
final uri = Uri.parse('http://localhost/');
late VersionStore store;
setUp(() {
store = _MockVersionStore();
});
test('returns 404 if release not found', () async {
when(() => store.filePathForVersion('1.0.0')).thenReturn('not-found');
final request = Request('GET', uri).provide(() => store);
final response = await downloadReleaseHandler(request, '1.0.0.txt');
expect(response.statusCode, HttpStatus.notFound);
});
test('returns 200 if release found', () async {
when(
() => store.filePathForVersion('1.0.0'),
).thenReturn(path.join('test', 'fixtures', 'release.txt'));
final request = Request('GET', uri).provide(() => store);
final response = await downloadReleaseHandler(request, '1.0.0.txt');
expect(response.statusCode, HttpStatus.ok);
});
});
}
@@ -1,11 +0,0 @@
// ignore_for_file: prefer_const_constructors
import 'package:shorebird_code_push_api/shorebird_code_push_api.dart';
import 'package:test/test.dart';
void main() {
group('ShorebirdCodePushApi', () {
test('can be instantiated', () {
expect(ShorebirdCodePushApi(), isNotNull);
});
});
}
@@ -0,0 +1,47 @@
import 'dart:io';
import 'package:shorebird_code_push_api/src/version_store.dart';
import 'package:test/test.dart';
void main() {
group('VersionStore', () {
group('getNextVersion', () {
test('returns 0.0.1 when no versions exist', () {
final tempDir = Directory.systemTemp.createTempSync();
final store = VersionStore(cachePath: tempDir.path);
expect(store.getNextVersion(), equals('0.0.1'));
});
test('returns 0.0.2 when 0.0.1 exists', () {
final tempDir = Directory.systemTemp.createTempSync();
final store = VersionStore(cachePath: tempDir.path)
..addVersion('0.0.1', []);
expect(store.getNextVersion(), equals('0.0.2'));
});
});
group('latestVersionForClient', () {
test('returns null when cache does not exist', () {
const store = VersionStore(cachePath: 'invalid-path');
expect(store.latestVersionForClient('empty-client-id'), isNull);
});
test('returns null when no versions exist', () {
final tempDir = Directory.systemTemp.createTempSync();
final store = VersionStore(cachePath: tempDir.path);
expect(store.latestVersionForClient('empty-client-id'), isNull);
});
test('returns latest version when multiple versions exist', () {
final tempDir = Directory.systemTemp.createTempSync();
final store = VersionStore(cachePath: tempDir.path)
..addVersion('0.0.1', [])
..addVersion('0.0.2', []);
expect(
store.latestVersionForClient('empty-client-id'),
equals('0.0.2'),
);
});
});
});
}