Compass App: Add "server" dart shelf-app and "shared" dart package (#2359)

This PR introduces two new subprojects:

- `compass_server` under `compass_app/server`.
- `compass_model` under `compass_app/model`.

**`compass_server`**

- Dart server implemented using `shelf`.
- Created with the `dart create -t server-shelf` template.
- Implements two REST endpoints:
  - `GET /continent`: Returns the list of `Continent` as JSON.
  - `GET /destination`: Returns the list of `Destination` as JSON.
- Generated Docker files have been removed.
- Implemented tests.
- TODO: Implement some basic auth.

**`compass_model`**

- Dart package to share data model classes between the `server` and
`app`.
- Contains the data model classes (`Continent`, `Destination`).
- Generated JSON from/To methods and data classes using `freezed`.
- The sole purpose of this package is to host the data model. Other
shared code should go in a different package.

**Other changes**

- Created an API Client to connect to the local dart server.
- Created "remote" repositories, which also implement a basic in-memory
caching strategy.
- Created two dependency configurations, one with local repositories and
one with remote repos.
- Created two application main targets to select configuration:
- `lib/main_development.dart` which starts the app with the "local" data
configuration.
- `lib/main_staging.dart` which starts the app with the "remove" (local
dart server) configuration.
  - `lib/main.dart` still works as default entry point.
- Implemented tests for remote repositories.

## Pre-launch Checklist

- [x] I read the [Flutter Style Guide] _recently_, and have followed its
advice.
- [x] I signed the [CLA].
- [x] I read the [Contributors Guide].
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] All existing and new tests are passing.

If you need help, consider asking for advice on the #hackers-devrel
channel on [Discord].

<!-- Links -->
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/master/docs/contributing/Style-guide-for-Flutter-repo.md
[CLA]: https://cla.developers.google.com/
[Discord]:
https://github.com/flutter/flutter/blob/master/docs/contributing/Chat.md
[Contributors Guide]:
https://github.com/flutter/samples/blob/main/CONTRIBUTING.md
pull/2389/head
Miguel Beltran 5 months ago committed by GitHub
parent 496b467485
commit be0b3dc0d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -3,12 +3,34 @@ import 'package:provider/provider.dart';
import '../data/repositories/continent/continent_repository.dart';
import '../data/repositories/continent/continent_repository_local.dart';
import '../data/repositories/continent/continent_repository_remote.dart';
import '../data/repositories/destination/destination_repository.dart';
import '../data/repositories/destination/destination_repository_local.dart';
import '../data/repositories/destination/destination_repository_remote.dart';
import '../data/services/api_client.dart';
/// Configure dependencies as a list of Providers
List<SingleChildWidget> get providers {
// List of Providers
/// Configure dependencies for remote data.
/// This dependency list uses repositories that connect to a remote server.
List<SingleChildWidget> get providersRemote {
final apiClient = ApiClient();
return [
Provider.value(
value: DestinationRepositoryRemote(
apiClient: apiClient,
) as DestinationRepository,
),
Provider.value(
value: ContinentRepositoryRemote(
apiClient: apiClient,
) as ContinentRepository,
),
];
}
/// Configure dependencies for local data.
/// This dependency list uses repositories that provide local data.
List<SingleChildWidget> get providersLocal {
return [
Provider.value(
value: DestinationRepositoryLocal() as DestinationRepository,

@ -1,12 +0,0 @@
class Continent {
/// e.g. 'Europe'
final String name;
/// e.g. 'https://rstr.in/google/tripedia/TmR12QdlVTT'
final String imageUrl;
Continent({
required this.name,
required this.imageUrl,
});
}

@ -1,50 +0,0 @@
/// Model class for Destination data
class Destination {
Destination({
required this.ref,
required this.name,
required this.country,
required this.continent,
required this.knownFor,
required this.tags,
required this.imageUrl,
});
/// e.g. 'alaska'
final String ref;
/// e.g. 'Alaska'
final String name;
/// e.g. 'United States'
final String country;
/// e.g. 'North America'
final String continent;
/// e.g. 'Alaska is a haven for outdoor enthusiasts ...'
final String knownFor;
/// e.g. ['Mountain', 'Off-the-beaten-path', 'Wildlife watching']
final List<String> tags;
/// e.g. 'https://storage.googleapis.com/tripedia-images/destinations/alaska.jpg'
final String imageUrl;
@override
String toString() {
return 'Destination{ref: $ref, name: $name, country: $country, continent: $continent, knownFor: $knownFor, tags: $tags, imageUrl: $imageUrl}';
}
factory Destination.fromJson(Map<String, dynamic> json) {
return Destination(
ref: json['ref'] as String,
name: json['name'] as String,
country: json['country'] as String,
continent: json['continent'] as String,
knownFor: json['knownFor'] as String,
tags: (json['tags'] as List<dynamic>).map((e) => e as String).toList(),
imageUrl: json['imageUrl'] as String,
);
}
}

@ -1,5 +1,6 @@
import 'package:compass_model/model.dart';
import '../../../utils/result.dart';
import '../../models/continent.dart';
/// Data source with all possible continents.
abstract class ContinentRepository {

@ -1,39 +1,40 @@
import 'package:compass_model/model.dart';
import '../../../utils/result.dart';
import '../../models/continent.dart';
import 'continent_repository.dart';
/// Local data source with all possible regions.
/// Local data source with all possible continents.
class ContinentRepositoryLocal implements ContinentRepository {
@override
Future<Result<List<Continent>>> getContinents() {
return Future.value(
Result.ok(
[
Continent(
const Continent(
name: 'Europe',
imageUrl: 'https://rstr.in/google/tripedia/TmR12QdlVTT',
),
Continent(
const Continent(
name: 'Asia',
imageUrl: 'https://rstr.in/google/tripedia/VJ8BXlQg8O1',
),
Continent(
const Continent(
name: 'South America',
imageUrl: 'https://rstr.in/google/tripedia/flm_-o1aI8e',
),
Continent(
const Continent(
name: 'Africa',
imageUrl: 'https://rstr.in/google/tripedia/-nzi8yFOBpF',
),
Continent(
const Continent(
name: 'North America',
imageUrl: 'https://rstr.in/google/tripedia/jlbgFDrSUVE',
),
Continent(
const Continent(
name: 'Oceania',
imageUrl: 'https://rstr.in/google/tripedia/vxyrDE-fZVL',
),
Continent(
const Continent(
name: 'Australia',
imageUrl: 'https://rstr.in/google/tripedia/z6vy6HeRyvZ',
),

@ -0,0 +1,34 @@
import 'package:compass_model/model.dart';
import '../../../utils/result.dart';
import '../../services/api_client.dart';
import 'continent_repository.dart';
/// Remote data source for [Continent].
/// Implements basic local caching.
/// See: https://docs.flutter.dev/get-started/fwe/local-caching
class ContinentRepositoryRemote implements ContinentRepository {
ContinentRepositoryRemote({
required ApiClient apiClient,
}) : _apiClient = apiClient;
final ApiClient _apiClient;
List<Continent>? _cachedData;
@override
Future<Result<List<Continent>>> getContinents() async {
if (_cachedData == null) {
// No cached data, request continents
final result = await _apiClient.getContinents();
if (result is Ok) {
// Store value if result Ok
_cachedData = result.asOk.value;
}
return result;
} else {
// Return cached data if available
return Result.ok(_cachedData!);
}
}
}

@ -1,5 +1,6 @@
import 'package:compass_model/model.dart';
import '../../../utils/result.dart';
import '../../models/destination.dart';
/// Data source with all possible destinations
abstract class DestinationRepository {

@ -1,11 +1,11 @@
import 'dart:convert';
import 'package:compass_model/model.dart';
import 'package:flutter/services.dart' show rootBundle;
import '../../../utils/result.dart';
import '../../models/destination.dart';
import 'destination_repository.dart';
import 'package:flutter/services.dart' show rootBundle;
/// Local implementation of DestinationRepository
/// Uses data from assets folder
class DestinationRepositoryLocal implements DestinationRepository {

@ -0,0 +1,34 @@
import 'package:compass_model/model.dart';
import '../../../utils/result.dart';
import '../../services/api_client.dart';
import 'destination_repository.dart';
/// Remote data source for [Destination].
/// Implements basic local caching.
/// See: https://docs.flutter.dev/get-started/fwe/local-caching
class DestinationRepositoryRemote implements DestinationRepository {
DestinationRepositoryRemote({
required ApiClient apiClient,
}) : _apiClient = apiClient;
final ApiClient _apiClient;
List<Destination>? _cachedData;
@override
Future<Result<List<Destination>>> getDestinations() async {
if (_cachedData == null) {
// No cached data, request destinations
final result = await _apiClient.getDestinations();
if (result is Ok) {
// Store value if result Ok
_cachedData = result.asOk.value;
}
return result;
} else {
// Return cached data if available
return Result.ok(_cachedData!);
}
}
}

@ -0,0 +1,49 @@
import 'dart:convert';
import 'dart:io';
import 'package:compass_model/model.dart';
import '../../utils/result.dart';
// TODO: Basic auth request
// TODO: Configurable baseurl/host/port
class ApiClient {
Future<Result<List<Continent>>> getContinents() async {
final client = HttpClient();
try {
final request = await client.get('localhost', 8080, '/continent');
final response = await request.close();
if (response.statusCode == 200) {
final stringData = await response.transform(utf8.decoder).join();
final json = jsonDecode(stringData) as List<dynamic>;
return Result.ok(
json.map((element) => Continent.fromJson(element)).toList());
} else {
return Result.error(const HttpException("Invalid response"));
}
} on Exception catch (error) {
return Result.error(error);
} finally {
client.close();
}
}
Future<Result<List<Destination>>> getDestinations() async {
final client = HttpClient();
try {
final request = await client.get('localhost', 8080, '/destination');
final response = await request.close();
if (response.statusCode == 200) {
final stringData = await response.transform(utf8.decoder).join();
final json = jsonDecode(stringData) as List<dynamic>;
return Result.ok(
json.map((element) => Destination.fromJson(element)).toList());
} else {
return Result.error(const HttpException("Invalid response"));
}
} on Exception catch (error) {
return Result.error(error);
} finally {
client.close();
}
}
}

@ -1,20 +1,14 @@
import 'config/dependencies.dart';
import 'ui/core/themes/theme.dart';
import 'routing/router.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'ui/core/ui/scroll_behavior.dart';
import 'main_development.dart' as development;
/// Default main method
void main() {
runApp(
MultiProvider(
// Loading the default providers
// NOTE: We can load different configurations e.g. fakes
providers: providers,
child: const MainApp(),
),
);
// Launch development config by default
development.main();
}
class MainApp extends StatelessWidget {

@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'config/dependencies.dart';
import 'main.dart';
/// Development config entry point.
/// Launch with `flutter run --target lib/main_development.dart`.
/// Uses local data.
void main() {
runApp(
MultiProvider(
providers: providersLocal,
child: const MainApp(),
),
);
}

@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'config/dependencies.dart';
import 'main.dart';
/// Staging config entry point.
/// Launch with `flutter run --target lib/main_staging.dart`.
/// Uses remote data from a server.
void main() {
runApp(
MultiProvider(
providers: providersRemote,
child: const MainApp(),
),
);
}

@ -13,7 +13,8 @@ final router = GoRouter(
GoRoute(
path: '/',
builder: (context, state) {
final viewModel = SearchFormViewModel(continentRepository: context.read());
final viewModel =
SearchFormViewModel(continentRepository: context.read());
return SearchFormScreen(viewModel: viewModel);
},
routes: [

@ -8,8 +8,7 @@ class AppColors {
static const grey3 = Color(0xFFA4A4A4);
static const whiteTransparent =
Color(0x4DFFFFFF); // Figma rgba(255, 255, 255, 0.3)
static const blackTransparent =
Color(0x4D000000);
static const blackTransparent = Color(0x4D000000);
static const lightColorScheme = ColorScheme(
brightness: Brightness.light,

@ -6,8 +6,8 @@ import 'package:flutter/material.dart';
class AppCustomScrollBehavior extends MaterialScrollBehavior {
@override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch,
// Allow to drag with mouse on Regions carousel
PointerDeviceKind.mouse,
};
PointerDeviceKind.touch,
// Allow to drag with mouse on Regions carousel
PointerDeviceKind.mouse,
};
}

@ -1,6 +1,7 @@
import 'package:compass_model/model.dart';
import '../../../data/repositories/destination/destination_repository.dart';
import '../../../utils/result.dart';
import '../../../data/models/destination.dart';
import 'package:flutter/cupertino.dart';
/// Results screen view model

@ -1,8 +1,9 @@
import 'package:compass_model/model.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import '../../core/themes/text_styles.dart';
import '../../core/ui/tag_chip.dart';
import '../../../data/models/destination.dart';
class ResultCard extends StatelessWidget {
const ResultCard({

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:compass_model/model.dart';
import '../../../data/models/continent.dart';
import '../../../data/repositories/continent/continent_repository.dart';
import '../../../utils/result.dart';
@ -33,7 +33,8 @@ class SearchFormViewModel extends ChangeNotifier {
/// Must be called only if [valid] is true
get searchQuery {
assert(valid, "Called searchQuery when the form is not valid");
assert(_selectedContinent != null, "Called searchQuery without a continent");
assert(
_selectedContinent != null, "Called searchQuery without a continent");
assert(_dateRange != null, "Called searchQuery without a date range");
assert(_guests > 0, "Called searchQuery without guests");
final startDate = _dateRange!.start;

@ -1,8 +1,8 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:compass_model/model.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../../../data/models/continent.dart';
import '../../core/themes/colors.dart';
import '../view_models/search_form_viewmodel.dart';

@ -8,6 +8,8 @@ environment:
dependencies:
cached_network_image: ^3.3.1
compass_model:
path: ../model
flutter:
sdk: flutter
go_router: ^14.2.0

@ -0,0 +1,45 @@
import 'package:compass_app/data/repositories/continent/continent_repository.dart';
import 'package:compass_app/data/repositories/continent/continent_repository_remote.dart';
import 'package:compass_app/utils/result.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../util/fakes/services/fake_api_client.dart';
void main() {
group('ContinentRepositoryRemote tests', () {
late FakeApiClient apiClient;
late ContinentRepository repository;
setUp(() {
apiClient = FakeApiClient();
repository = ContinentRepositoryRemote(apiClient: apiClient);
});
test('should get continents', () async {
final result = await repository.getContinents();
expect(result, isA<Ok>());
final list = result.asOk.value;
expect(list.length, 3);
final destination = list.first;
expect(destination.name, 'CONTINENT');
// Only one request happened
expect(apiClient.requestCount, 1);
});
test('should get continents from cache', () async {
// Request continents once
var result = await repository.getContinents();
expect(result, isA<Ok>());
// Request continents another time
result = await repository.getContinents();
expect(result, isA<Ok>());
// Only one request happened
expect(apiClient.requestCount, 1);
});
});
}

@ -0,0 +1,45 @@
import 'package:compass_app/data/repositories/destination/destination_repository.dart';
import 'package:compass_app/data/repositories/destination/destination_repository_remote.dart';
import 'package:compass_app/utils/result.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../../util/fakes/services/fake_api_client.dart';
void main() {
group('DestinationRepositoryRemote tests', () {
late FakeApiClient apiClient;
late DestinationRepository repository;
setUp(() {
apiClient = FakeApiClient();
repository = DestinationRepositoryRemote(apiClient: apiClient);
});
test('should get destinations', () async {
final result = await repository.getDestinations();
expect(result, isA<Ok>());
final list = result.asOk.value;
expect(list.length, 2);
final destination = list.first;
expect(destination.name, 'name1');
// Only one request happened
expect(apiClient.requestCount, 1);
});
test('should get destinations from cache', () async {
// Request destination once
var result = await repository.getDestinations();
expect(result, isA<Ok>());
// Request destination another time
result = await repository.getDestinations();
expect(result, isA<Ok>());
// Only one request happened
expect(apiClient.requestCount, 1);
});
});
}

@ -9,7 +9,8 @@ void main() {
late SearchFormViewModel viewModel;
setUp(() {
viewModel = SearchFormViewModel(continentRepository: FakeContinentRepository());
viewModel =
SearchFormViewModel(continentRepository: FakeContinentRepository());
});
test('Initial values are correct', () {
@ -58,7 +59,8 @@ void main() {
viewModel.dateRange = newDateRange;
expect(viewModel.valid, true);
expect(viewModel.searchQuery, 'continent=CONTINENT&checkIn=2024-01-01&checkOut=2024-01-31&guests=2');
expect(viewModel.searchQuery,
'continent=CONTINENT&checkIn=2024-01-01&checkOut=2024-01-31&guests=2');
});
});
}

@ -30,7 +30,8 @@ void main() {
});
}
testWidgets('Should load and select continent', (WidgetTester tester) async {
testWidgets('Should load and select continent',
(WidgetTester tester) async {
await loadWidget(tester);
expect(find.byType(SearchFormContinent), findsOneWidget);

@ -27,7 +27,8 @@ void main() {
);
}
testWidgets('should display date in different month', (WidgetTester tester) async {
testWidgets('should display date in different month',
(WidgetTester tester) async {
await loadWidget(tester);
expect(find.byType(SearchFormDate), findsOneWidget);
@ -35,13 +36,15 @@ void main() {
expect(find.text('Add Dates'), findsOneWidget);
// Simulate date picker input:
viewModel.dateRange = DateTimeRange(start: DateTime(2024, 6, 12), end: DateTime(2024, 7, 23));
viewModel.dateRange = DateTimeRange(
start: DateTime(2024, 6, 12), end: DateTime(2024, 7, 23));
await tester.pumpAndSettle();
expect(find.text('12 Jun - 23 Jul'), findsOneWidget);
});
testWidgets('should display date in same month', (WidgetTester tester) async {
testWidgets('should display date in same month',
(WidgetTester tester) async {
await loadWidget(tester);
expect(find.byType(SearchFormDate), findsOneWidget);
@ -49,7 +52,8 @@ void main() {
expect(find.text('Add Dates'), findsOneWidget);
// Simulate date picker input:
viewModel.dateRange = DateTimeRange(start: DateTime(2024, 6, 12), end: DateTime(2024, 6, 23));
viewModel.dateRange = DateTimeRange(
start: DateTime(2024, 6, 12), end: DateTime(2024, 6, 23));
await tester.pumpAndSettle();
expect(find.text('12 - 23 Jun'), findsOneWidget);

@ -38,7 +38,8 @@ void main() {
});
}
testWidgets('Should fill form and perform search', (WidgetTester tester) async {
testWidgets('Should fill form and perform search',
(WidgetTester tester) async {
await loadWidget(tester);
expect(find.byType(SearchFormScreen), findsOneWidget);
@ -46,7 +47,8 @@ void main() {
await tester.tap(find.text('CONTINENT'), warnIfMissed: false);
// Select date
viewModel.dateRange = DateTimeRange(start: DateTime(2024, 6, 12), end: DateTime(2024, 7, 23));
viewModel.dateRange = DateTimeRange(
start: DateTime(2024, 6, 12), end: DateTime(2024, 7, 23));
// Select guests
await tester.tap(find.byKey(const ValueKey('add_guests')));
@ -58,7 +60,9 @@ void main() {
await tester.tap(find.byKey(const ValueKey('submit_button')));
// Should navigate to results screen
verify(() => goRouter.go('/results?continent=CONTINENT&checkIn=2024-06-12&checkOut=2024-07-23&guests=1')).called(1);
verify(() => goRouter.go(
'/results?continent=CONTINENT&checkIn=2024-06-12&checkOut=2024-07-23&guests=1'))
.called(1);
});
});
}

@ -1,4 +1,4 @@
import 'package:compass_app/data/models/continent.dart';
import 'package:compass_model/model.dart';
import 'package:compass_app/data/repositories/continent/continent_repository.dart';
import 'package:compass_app/utils/result.dart';
@ -6,9 +6,9 @@ class FakeContinentRepository implements ContinentRepository {
@override
Future<Result<List<Continent>>> getContinents() async {
return Result.ok([
Continent(name: 'CONTINENT', imageUrl: 'URL'),
Continent(name: 'CONTINENT2', imageUrl: 'URL'),
Continent(name: 'CONTINENT3', imageUrl: 'URL'),
const Continent(name: 'CONTINENT', imageUrl: 'URL'),
const Continent(name: 'CONTINENT2', imageUrl: 'URL'),
const Continent(name: 'CONTINENT3', imageUrl: 'URL'),
]);
}
}

@ -1,4 +1,4 @@
import 'package:compass_app/data/models/destination.dart';
import 'package:compass_model/model.dart';
import 'package:compass_app/data/repositories/destination/destination_repository.dart';
import 'package:compass_app/utils/result.dart';
@ -9,7 +9,7 @@ class FakeDestinationRepository implements DestinationRepository {
return Future.value(
Result.ok(
[
Destination(
const Destination(
ref: 'ref1',
name: 'name1',
country: 'country1',
@ -18,7 +18,7 @@ class FakeDestinationRepository implements DestinationRepository {
tags: ['tags1'],
imageUrl: 'imageUrl1',
),
Destination(
const Destination(
ref: 'ref2',
name: 'name2',
country: 'country2',

@ -0,0 +1,45 @@
import 'package:compass_app/data/services/api_client.dart';
import 'package:compass_app/utils/result.dart';
import 'package:compass_model/model.dart';
class FakeApiClient implements ApiClient {
// Should not increase when using cached data
int requestCount = 0;
@override
Future<Result<List<Continent>>> getContinents() async {
requestCount++;
return Result.ok([
const Continent(name: 'CONTINENT', imageUrl: 'URL'),
const Continent(name: 'CONTINENT2', imageUrl: 'URL'),
const Continent(name: 'CONTINENT3', imageUrl: 'URL'),
]);
}
@override
Future<Result<List<Destination>>> getDestinations() async {
requestCount++;
return Result.ok(
[
const Destination(
ref: 'ref1',
name: 'name1',
country: 'country1',
continent: 'Europe',
knownFor: 'knownFor1',
tags: ['tags1'],
imageUrl: 'imageUrl1',
),
const Destination(
ref: 'ref2',
name: 'name2',
country: 'country2',
continent: 'Europe',
knownFor: 'knownFor2',
tags: ['tags2'],
imageUrl: 'imageUrl2',
),
],
);
}
}

@ -0,0 +1,7 @@
# https://dart.dev/guides/libraries/private-files
# Created by `dart pub`
.dart_tool/
# Avoid committing pubspec.lock for library packages; see
# https://dart.dev/guides/libraries/private-files#pubspeclock.
pubspec.lock

@ -0,0 +1,3 @@
# compass_model
Shared Data Model for the `compass_app` example.

@ -0,0 +1,30 @@
# This file configures the static analysis results for your project (errors,
# warnings, and lints).
#
# This enables the 'recommended' set of lints from `package:lints`.
# This set helps identify many issues that may lead to problems when running
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
# style and format.
#
# If you want a smaller set of lints you can change this to specify
# 'package:lints/core.yaml'. These are just the most critical lints
# (the recommended set includes the core lints).
# The core lints are also what is used by pub.dev for scoring packages.
include: package:lints/recommended.yaml
# Uncomment the following section to specify additional rules.
# linter:
# rules:
# - camel_case_types
# analyzer:
# exclude:
# - path/to/excluded/files/**
# For more information about the core and recommended set of lints, see
# https://dart.dev/go/core-lints
# For additional information about configuring this file, see
# https://dart.dev/guides/language/analysis-options

@ -0,0 +1,4 @@
library;
export 'src/model/continent/continent.dart';
export 'src/model/destination/destination.dart';

@ -0,0 +1,19 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'continent.freezed.dart';
part 'continent.g.dart';
@freezed
class Continent with _$Continent {
const factory Continent({
/// e.g. 'Europe'
required String name,
/// e.g. 'https://rstr.in/google/tripedia/TmR12QdlVTT'
required String imageUrl,
}) = _Continent;
factory Continent.fromJson(Map<String, Object?> json) =>
_$ContinentFromJson(json);
}

@ -0,0 +1,191 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'continent.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
Continent _$ContinentFromJson(Map<String, dynamic> json) {
return _Continent.fromJson(json);
}
/// @nodoc
mixin _$Continent {
/// e.g. 'Europe'
String get name => throw _privateConstructorUsedError;
/// e.g. 'https://rstr.in/google/tripedia/TmR12QdlVTT'
String get imageUrl => throw _privateConstructorUsedError;
/// Serializes this Continent to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of Continent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$ContinentCopyWith<Continent> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $ContinentCopyWith<$Res> {
factory $ContinentCopyWith(Continent value, $Res Function(Continent) then) =
_$ContinentCopyWithImpl<$Res, Continent>;
@useResult
$Res call({String name, String imageUrl});
}
/// @nodoc
class _$ContinentCopyWithImpl<$Res, $Val extends Continent>
implements $ContinentCopyWith<$Res> {
_$ContinentCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of Continent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? name = null,
Object? imageUrl = null,
}) {
return _then(_value.copyWith(
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
imageUrl: null == imageUrl
? _value.imageUrl
: imageUrl // ignore: cast_nullable_to_non_nullable
as String,
) as $Val);
}
}
/// @nodoc
abstract class _$$ContinentImplCopyWith<$Res>
implements $ContinentCopyWith<$Res> {
factory _$$ContinentImplCopyWith(
_$ContinentImpl value, $Res Function(_$ContinentImpl) then) =
__$$ContinentImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({String name, String imageUrl});
}
/// @nodoc
class __$$ContinentImplCopyWithImpl<$Res>
extends _$ContinentCopyWithImpl<$Res, _$ContinentImpl>
implements _$$ContinentImplCopyWith<$Res> {
__$$ContinentImplCopyWithImpl(
_$ContinentImpl _value, $Res Function(_$ContinentImpl) _then)
: super(_value, _then);
/// Create a copy of Continent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? name = null,
Object? imageUrl = null,
}) {
return _then(_$ContinentImpl(
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
imageUrl: null == imageUrl
? _value.imageUrl
: imageUrl // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
@JsonSerializable()
class _$ContinentImpl implements _Continent {
const _$ContinentImpl({required this.name, required this.imageUrl});
factory _$ContinentImpl.fromJson(Map<String, dynamic> json) =>
_$$ContinentImplFromJson(json);
/// e.g. 'Europe'
@override
final String name;
/// e.g. 'https://rstr.in/google/tripedia/TmR12QdlVTT'
@override
final String imageUrl;
@override
String toString() {
return 'Continent(name: $name, imageUrl: $imageUrl)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$ContinentImpl &&
(identical(other.name, name) || other.name == name) &&
(identical(other.imageUrl, imageUrl) ||
other.imageUrl == imageUrl));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, name, imageUrl);
/// Create a copy of Continent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$ContinentImplCopyWith<_$ContinentImpl> get copyWith =>
__$$ContinentImplCopyWithImpl<_$ContinentImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$ContinentImplToJson(
this,
);
}
}
abstract class _Continent implements Continent {
const factory _Continent(
{required final String name,
required final String imageUrl}) = _$ContinentImpl;
factory _Continent.fromJson(Map<String, dynamic> json) =
_$ContinentImpl.fromJson;
/// e.g. 'Europe'
@override
String get name;
/// e.g. 'https://rstr.in/google/tripedia/TmR12QdlVTT'
@override
String get imageUrl;
/// Create a copy of Continent
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$ContinentImplCopyWith<_$ContinentImpl> get copyWith =>
throw _privateConstructorUsedError;
}

@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'continent.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$ContinentImpl _$$ContinentImplFromJson(Map<String, dynamic> json) =>
_$ContinentImpl(
name: json['name'] as String,
imageUrl: json['imageUrl'] as String,
);
Map<String, dynamic> _$$ContinentImplToJson(_$ContinentImpl instance) =>
<String, dynamic>{
'name': instance.name,
'imageUrl': instance.imageUrl,
};

@ -0,0 +1,34 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'destination.freezed.dart';
part 'destination.g.dart';
@freezed
class Destination with _$Destination {
const factory Destination({
/// e.g. 'alaska'
required String ref,
/// e.g. 'Alaska'
required String name,
/// e.g. 'United States'
required String country,
/// e.g. 'North America'
required String continent,
/// e.g. 'Alaska is a haven for outdoor enthusiasts ...'
required String knownFor,
/// e.g. ['Mountain', 'Off-the-beaten-path', 'Wildlife watching']
required List<String> tags,
/// e.g. 'https://storage.googleapis.com/tripedia-images/destinations/alaska.jpg'
required String imageUrl,
}) = _Destination;
factory Destination.fromJson(Map<String, Object?> json) =>
_$DestinationFromJson(json);
}

@ -0,0 +1,339 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'destination.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
Destination _$DestinationFromJson(Map<String, dynamic> json) {
return _Destination.fromJson(json);
}
/// @nodoc
mixin _$Destination {
/// e.g. 'alaska'
String get ref => throw _privateConstructorUsedError;
/// e.g. 'Alaska'
String get name => throw _privateConstructorUsedError;
/// e.g. 'United States'
String get country => throw _privateConstructorUsedError;
/// e.g. 'North America'
String get continent => throw _privateConstructorUsedError;
/// e.g. 'Alaska is a haven for outdoor enthusiasts ...'
String get knownFor => throw _privateConstructorUsedError;
/// e.g. ['Mountain', 'Off-the-beaten-path', 'Wildlife watching']
List<String> get tags => throw _privateConstructorUsedError;
/// e.g. 'https://storage.googleapis.com/tripedia-images/destinations/alaska.jpg'
String get imageUrl => throw _privateConstructorUsedError;
/// Serializes this Destination to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of Destination
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$DestinationCopyWith<Destination> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $DestinationCopyWith<$Res> {
factory $DestinationCopyWith(
Destination value, $Res Function(Destination) then) =
_$DestinationCopyWithImpl<$Res, Destination>;
@useResult
$Res call(
{String ref,
String name,
String country,
String continent,
String knownFor,
List<String> tags,
String imageUrl});
}
/// @nodoc
class _$DestinationCopyWithImpl<$Res, $Val extends Destination>
implements $DestinationCopyWith<$Res> {
_$DestinationCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of Destination
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? ref = null,
Object? name = null,
Object? country = null,
Object? continent = null,
Object? knownFor = null,
Object? tags = null,
Object? imageUrl = null,
}) {
return _then(_value.copyWith(
ref: null == ref
? _value.ref
: ref // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
country: null == country
? _value.country
: country // ignore: cast_nullable_to_non_nullable
as String,
continent: null == continent
? _value.continent
: continent // ignore: cast_nullable_to_non_nullable
as String,
knownFor: null == knownFor
? _value.knownFor
: knownFor // ignore: cast_nullable_to_non_nullable
as String,
tags: null == tags
? _value.tags
: tags // ignore: cast_nullable_to_non_nullable
as List<String>,
imageUrl: null == imageUrl
? _value.imageUrl
: imageUrl // ignore: cast_nullable_to_non_nullable
as String,
) as $Val);
}
}
/// @nodoc
abstract class _$$DestinationImplCopyWith<$Res>
implements $DestinationCopyWith<$Res> {
factory _$$DestinationImplCopyWith(
_$DestinationImpl value, $Res Function(_$DestinationImpl) then) =
__$$DestinationImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String ref,
String name,
String country,
String continent,
String knownFor,
List<String> tags,
String imageUrl});
}
/// @nodoc
class __$$DestinationImplCopyWithImpl<$Res>
extends _$DestinationCopyWithImpl<$Res, _$DestinationImpl>
implements _$$DestinationImplCopyWith<$Res> {
__$$DestinationImplCopyWithImpl(
_$DestinationImpl _value, $Res Function(_$DestinationImpl) _then)
: super(_value, _then);
/// Create a copy of Destination
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? ref = null,
Object? name = null,
Object? country = null,
Object? continent = null,
Object? knownFor = null,
Object? tags = null,
Object? imageUrl = null,
}) {
return _then(_$DestinationImpl(
ref: null == ref
? _value.ref
: ref // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
country: null == country
? _value.country
: country // ignore: cast_nullable_to_non_nullable
as String,
continent: null == continent
? _value.continent
: continent // ignore: cast_nullable_to_non_nullable
as String,
knownFor: null == knownFor
? _value.knownFor
: knownFor // ignore: cast_nullable_to_non_nullable
as String,
tags: null == tags
? _value._tags
: tags // ignore: cast_nullable_to_non_nullable
as List<String>,
imageUrl: null == imageUrl
? _value.imageUrl
: imageUrl // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
@JsonSerializable()
class _$DestinationImpl implements _Destination {
const _$DestinationImpl(
{required this.ref,
required this.name,
required this.country,
required this.continent,
required this.knownFor,
required final List<String> tags,
required this.imageUrl})
: _tags = tags;
factory _$DestinationImpl.fromJson(Map<String, dynamic> json) =>
_$$DestinationImplFromJson(json);
/// e.g. 'alaska'
@override
final String ref;
/// e.g. 'Alaska'
@override
final String name;
/// e.g. 'United States'
@override
final String country;
/// e.g. 'North America'
@override
final String continent;
/// e.g. 'Alaska is a haven for outdoor enthusiasts ...'
@override
final String knownFor;
/// e.g. ['Mountain', 'Off-the-beaten-path', 'Wildlife watching']
final List<String> _tags;
/// e.g. ['Mountain', 'Off-the-beaten-path', 'Wildlife watching']
@override
List<String> get tags {
if (_tags is EqualUnmodifiableListView) return _tags;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_tags);
}
/// e.g. 'https://storage.googleapis.com/tripedia-images/destinations/alaska.jpg'
@override
final String imageUrl;
@override
String toString() {
return 'Destination(ref: $ref, name: $name, country: $country, continent: $continent, knownFor: $knownFor, tags: $tags, imageUrl: $imageUrl)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$DestinationImpl &&
(identical(other.ref, ref) || other.ref == ref) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.country, country) || other.country == country) &&
(identical(other.continent, continent) ||
other.continent == continent) &&
(identical(other.knownFor, knownFor) ||
other.knownFor == knownFor) &&
const DeepCollectionEquality().equals(other._tags, _tags) &&
(identical(other.imageUrl, imageUrl) ||
other.imageUrl == imageUrl));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, ref, name, country, continent,
knownFor, const DeepCollectionEquality().hash(_tags), imageUrl);
/// Create a copy of Destination
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$DestinationImplCopyWith<_$DestinationImpl> get copyWith =>
__$$DestinationImplCopyWithImpl<_$DestinationImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$DestinationImplToJson(
this,
);
}
}
abstract class _Destination implements Destination {
const factory _Destination(
{required final String ref,
required final String name,
required final String country,
required final String continent,
required final String knownFor,
required final List<String> tags,
required final String imageUrl}) = _$DestinationImpl;
factory _Destination.fromJson(Map<String, dynamic> json) =
_$DestinationImpl.fromJson;
/// e.g. 'alaska'
@override
String get ref;
/// e.g. 'Alaska'
@override
String get name;
/// e.g. 'United States'
@override
String get country;
/// e.g. 'North America'
@override
String get continent;
/// e.g. 'Alaska is a haven for outdoor enthusiasts ...'
@override
String get knownFor;
/// e.g. ['Mountain', 'Off-the-beaten-path', 'Wildlife watching']
@override
List<String> get tags;
/// e.g. 'https://storage.googleapis.com/tripedia-images/destinations/alaska.jpg'
@override
String get imageUrl;
/// Create a copy of Destination
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$DestinationImplCopyWith<_$DestinationImpl> get copyWith =>
throw _privateConstructorUsedError;
}

@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'destination.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$DestinationImpl _$$DestinationImplFromJson(Map<String, dynamic> json) =>
_$DestinationImpl(
ref: json['ref'] as String,
name: json['name'] as String,
country: json['country'] as String,
continent: json['continent'] as String,
knownFor: json['knownFor'] as String,
tags: (json['tags'] as List<dynamic>).map((e) => e as String).toList(),
imageUrl: json['imageUrl'] as String,
);
Map<String, dynamic> _$$DestinationImplToJson(_$DestinationImpl instance) =>
<String, dynamic>{
'ref': instance.ref,
'name': instance.name,
'country': instance.country,
'continent': instance.continent,
'knownFor': instance.knownFor,
'tags': instance.tags,
'imageUrl': instance.imageUrl,
};

@ -0,0 +1,17 @@
name: compass_model
description: Compass App Data Model
publish_to: 'none'
version: 1.0.0
environment:
sdk: ^3.4.3
dev_dependencies:
build_runner: ^2.4.11
freezed: ^2.5.7
json_serializable: ^6.8.0
lints: ^3.0.0
test: ^1.24.0
dependencies:
freezed_annotation: ^2.4.4
json_annotation: ^4.9.0

@ -0,0 +1,3 @@
# https://dart.dev/guides/libraries/private-files
# Created by `dart pub`
.dart_tool/

@ -0,0 +1,13 @@
A server app built using [Shelf](https://pub.dev/packages/shelf).
# Running the server
## Running with the Dart SDK
You can run the example with the [Dart SDK](https://dart.dev/get-dart)
like this:
```
$ dart run
Server listening on port 8080
```

@ -0,0 +1,30 @@
# This file configures the static analysis results for your project (errors,
# warnings, and lints).
#
# This enables the 'recommended' set of lints from `package:lints`.
# This set helps identify many issues that may lead to problems when running
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
# style and format.
#
# If you want a smaller set of lints you can change this to specify
# 'package:lints/core.yaml'. These are just the most critical lints
# (the recommended set includes the core lints).
# The core lints are also what is used by pub.dev for scoring packages.
include: package:lints/recommended.yaml
# Uncomment the following section to specify additional rules.
# linter:
# rules:
# - camel_case_types
# analyzer:
# exclude:
# - path/to/excluded/files/**
# For more information about the core and recommended set of lints, see
# https://dart.dev/go/core-lints
# For additional information about configuring this file, see
# https://dart.dev/guides/language/analysis-options

File diff suppressed because it is too large Load Diff

@ -0,0 +1,26 @@
import 'dart:io';
import 'package:compass_server/routes/continent.dart';
import 'package:compass_server/routes/destination.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart';
import 'package:shelf_router/shelf_router.dart';
// Configure routes.
final _router = Router()
..get('/continent', continentHandler)
..get('/destination', destinationHandler);
void main(List<String> args) async {
// Use any available host or container IP (usually `0.0.0.0`).
final ip = InternetAddress.anyIPv4;
// Configure a pipeline that logs requests.
final handler =
Pipeline().addMiddleware(logRequests()).addHandler(_router.call);
// For running in containers, we respect the PORT environment variable.
final port = int.parse(Platform.environment['PORT'] ?? '8080');
final server = await serve(handler, ip, port);
print('Server listening on port ${server.port}');
}

@ -0,0 +1,39 @@
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:compass_model/model.dart';
final _continents = [
Continent(
name: 'Europe',
imageUrl: 'https://rstr.in/google/tripedia/TmR12QdlVTT',
),
Continent(
name: 'Asia',
imageUrl: 'https://rstr.in/google/tripedia/VJ8BXlQg8O1',
),
Continent(
name: 'South America',
imageUrl: 'https://rstr.in/google/tripedia/flm_-o1aI8e',
),
Continent(
name: 'Africa',
imageUrl: 'https://rstr.in/google/tripedia/-nzi8yFOBpF',
),
Continent(
name: 'North America',
imageUrl: 'https://rstr.in/google/tripedia/jlbgFDrSUVE',
),
Continent(
name: 'Oceania',
imageUrl: 'https://rstr.in/google/tripedia/vxyrDE-fZVL',
),
Continent(
name: 'Australia',
imageUrl: 'https://rstr.in/google/tripedia/z6vy6HeRyvZ',
),
];
Response continentHandler(Request req) {
return Response.ok(jsonEncode(_continents));
}

@ -0,0 +1,9 @@
import 'dart:io';
import 'package:shelf/shelf.dart';
Future<Response> destinationHandler(Request req) async {
final file = File('assets/destinations.json');
final jsonString = await file.readAsString();
return Response.ok(jsonString);
}

@ -0,0 +1,19 @@
name: compass_server
description: A server app using the shelf package and Docker.
publish_to: 'none'
version: 1.0.0
environment:
sdk: ^3.4.3
dependencies:
args: ^2.4.0
shelf: ^1.4.0
shelf_router: ^1.1.0
compass_model:
path: ../model
dev_dependencies:
http: ^1.1.0
lints: ^3.0.0
test: ^1.24.0

@ -0,0 +1,53 @@
import 'dart:convert';
import 'dart:io';
import 'package:compass_model/model.dart';
import 'package:http/http.dart';
import 'package:test/test.dart';
void main() {
final port = '8080';
final host = 'http://0.0.0.0:$port';
late Process p;
setUp(() async {
p = await Process.start(
'dart',
['run', 'bin/compass_server.dart'],
environment: {'PORT': port},
);
// Wait for server to start and print to stdout.
await p.stdout.first;
});
tearDown(() => p.kill());
test('Get Continent end-point', () async {
// Query /continent end-point
final response = await get(Uri.parse('$host/continent'));
expect(response.statusCode, 200);
// Parse json response list
final list = jsonDecode(response.body) as List<dynamic>;
// Parse items
final continents = list.map((element) => Continent.fromJson(element));
expect(continents.length, 7);
expect(continents.first.name, 'Europe');
});
test('Get Destination end-point', () async {
// Query /destination end-point
final response = await get(Uri.parse('$host/destination'));
expect(response.statusCode, 200);
// Parse json response list
final list = jsonDecode(response.body) as List<dynamic>;
// Parse items
final destination = list.map((element) => Destination.fromJson(element));
expect(destination.length, 137);
expect(destination.first.name, 'Alaska');
});
test('404', () async {
final response = await get(Uri.parse('$host/foobar'));
expect(response.statusCode, 404);
});
}
Loading…
Cancel
Save