feat(shorebird_code_push_api): engine download endpoint and api key verification (#17)

This commit is contained in:
Felix Angelov
2023-03-06 11:23:27 -06:00
committed by GitHub
parent 176ea882ce
commit d2a5cccd3a
12 changed files with 205 additions and 1 deletions
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:shelf/shelf.dart';
@@ -12,10 +13,19 @@ Future<void> main() async {
..all('/', (_) => Response(HttpStatus.noContent))
..post('/api/v1/updates', checkForUpdatesHandler)
..get('/api/v1/releases/<version>', downloadReleaseHandler)
..post('/api/v1/releases', uploadReleaseHandler);
..post('/api/v1/releases', uploadReleaseHandler)
..get('/api/v1/engines/<revision>', downloadEngineHandler);
final apiKeys = json.decode(
Platform.environment['CODE_PUSH_API_KEYS'] ?? '[]',
) as List;
final gcpKey = Platform.environment['GCP_SA'] ?? '';
final handler = const Pipeline()
.addMiddleware(versionStoreProvider)
.addMiddleware(httpClientProvider(gcpKey))
.addMiddleware(apiKeyVerifier(keys: apiKeys.cast<String>()))
.addHandler(router.call);
final server = await shelf_io.serve(
@@ -0,0 +1,19 @@
import 'package:googleapis_auth/auth_io.dart';
import 'package:http/http.dart' as http;
const _scopes = [
// Cloud Storage
'https://www.googleapis.com/auth/devstorage.read_write',
];
Future<http.Client> createClient(String key) async {
try {
// coverage:ignore-start
final serviceAccount = ServiceAccountCredentials.fromJson(key);
final client = await clientViaServiceAccount(serviceAccount, _scopes);
return client;
// coverage:ignore-end
} catch (_) {
return http.Client();
}
}
@@ -0,0 +1,13 @@
import 'dart:io';
import 'package:shelf/shelf.dart';
Middleware apiKeyVerifier({List<String> keys = const []}) {
return (handler) {
return (request) async {
final apiKey = request.headers['x-api-key'];
if (!keys.contains(apiKey)) return Response(HttpStatus.unauthorized);
return handler(request);
};
};
}
@@ -0,0 +1,12 @@
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shorebird_code_push_api/src/http_client.dart';
import 'package:shorebird_code_push_api/src/provider.dart';
Middleware httpClientProvider(String key) {
return provider<Future<http.Client>>(
(_) async => _httpClient ??= createClient(key),
);
}
Future<http.Client>? _httpClient;
@@ -1 +1,3 @@
export 'api_key_verifier.dart';
export 'http_client_provider.dart';
export 'version_store_provider.dart';
@@ -0,0 +1 @@
export 'download_engine_handler.dart';
@@ -0,0 +1,26 @@
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:shelf/shelf.dart';
import 'package:shorebird_code_push_api/src/provider.dart';
final _engineUrl = Uri.parse(
'https://storage.googleapis.com/download/storage/v1/b/shorebird-code-push-api.appspot.com/o/${Uri.encodeComponent('engines/engine.zip')}?alt=media',
);
Future<Response> downloadEngineHandler(Request request, String revision) async {
final httpClient = await request.lookup<Future<http.Client>>();
final response = await httpClient.get(
_engineUrl,
headers: {
'Content-Type': 'application/octet-stream',
'Connection': 'close'
},
);
if (response.statusCode != HttpStatus.ok) {
return Response(response.statusCode, body: response.body);
}
return Response.ok(response.bodyBytes);
}
@@ -1,3 +1,4 @@
export 'check_for_updates/check_for_updates.dart';
export 'download_engine/download_engine.dart';
export 'download_release/download_release.dart';
export 'upload_release/upload_release.dart';
@@ -7,6 +7,8 @@ environment:
sdk: ">=2.19.0 <3.0.0"
dependencies:
googleapis_auth: ^1.3.1
http: ^0.13.5
path: ^1.8.3
shelf: ^1.4.0
shelf_multipart: ^1.0.0
@@ -0,0 +1,49 @@
import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shorebird_code_push_api/src/middleware/middleware.dart';
import 'package:test/test.dart';
void main() {
group('apiKeyVerifier', () {
const keys = ['valid-key'];
test('returns 401 if no key is provided', () async {
final handler = const Pipeline()
.addMiddleware(apiKeyVerifier())
.addHandler((_) => Response.ok('OK'));
final request = Request('GET', Uri.parse('http://localhost/'));
final response = await handler(request);
expect(response.statusCode, equals(HttpStatus.unauthorized));
});
test('returns 401 if key is invalid', () async {
final handler = const Pipeline()
.addMiddleware(apiKeyVerifier(keys: keys))
.addHandler((_) => Response.ok('OK'));
final request = Request(
'GET',
Uri.parse('http://localhost/'),
headers: {'x-api-key': 'invalid-key'},
);
final response = await handler(request);
expect(response.statusCode, equals(HttpStatus.unauthorized));
});
test('returns 200 if key is valid', () async {
final handler = const Pipeline()
.addMiddleware(apiKeyVerifier(keys: keys))
.addHandler((_) => Response.ok('OK'));
final request = Request(
'GET',
Uri.parse('http://localhost/'),
headers: {'x-api-key': 'valid-key'},
);
final response = await handler(request);
expect(response.statusCode, equals(HttpStatus.ok));
});
});
}
@@ -0,0 +1,24 @@
import 'package:http/http.dart' as http;
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:test/test.dart';
void main() {
group('httpClientProvider', () {
test('provides an http client instance', () async {
Future<http.Client>? client;
final handler = httpClientProvider('')(
(req) async {
client = req.lookup<Future<http.Client>>();
return Response.ok('');
},
);
final request = Request('GET', Uri.parse('http://localhost/'));
await handler(request);
expect(client, isNotNull);
});
});
}
@@ -0,0 +1,45 @@
import 'dart:io';
import 'package:http/http.dart' as http;
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/download_engine/download_engine.dart';
import 'package:test/test.dart';
class _MockHttpClient extends Mock implements http.Client {}
void main() {
group('downloadEngineHandler', () {
final uri = Uri.parse('http://localhost/');
late http.Client httpClient;
setUpAll(() {
registerFallbackValue(Uri());
});
setUp(() {
httpClient = _MockHttpClient();
});
test('returns error on failure', () async {
when(
() => httpClient.get(any(), headers: any(named: 'headers')),
).thenAnswer((_) async => http.Response('oops', HttpStatus.unauthorized));
final request = Request('GET', uri).provide(() async => httpClient);
final response = await downloadEngineHandler(request, 'revision');
expect(response.statusCode, equals(HttpStatus.unauthorized));
});
test('returns bytes on success', () async {
when(
() => httpClient.get(any(), headers: any(named: 'headers')),
).thenAnswer((_) async => http.Response('OK', HttpStatus.ok));
final request = Request('GET', uri).provide(() async => httpClient);
final response = await downloadEngineHandler(request, 'revision');
expect(response.statusCode, equals(HttpStatus.ok));
});
});
}