1.1.0-alpha
This commit is contained in:
parent
6ff96bff49
commit
eabf105bd6
9 changed files with 194 additions and 225 deletions
|
@ -4,7 +4,7 @@ library angel_client;
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/src/response.dart' as http;
|
import 'package:http/src/response.dart' as http;
|
||||||
export 'package:angel_framework/src/http/angel_http_exception.dart';
|
export 'package:angel_http_exception/angel_http_exception.dart';
|
||||||
|
|
||||||
/// A function that configures an [Angel] client in some way.
|
/// A function that configures an [Angel] client in some way.
|
||||||
typedef Future AngelConfigurer(Angel app);
|
typedef Future AngelConfigurer(Angel app);
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:angel_framework/src/http/angel_http_exception.dart';
|
import 'package:angel_http_exception/angel_http_exception.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:http/src/base_client.dart' as http;
|
import 'package:http/src/base_client.dart' as http;
|
||||||
import 'package:http/src/base_request.dart' as http;
|
import 'package:http/src/base_request.dart' as http;
|
||||||
|
@ -72,17 +72,29 @@ abstract class BaseAngelClient extends Angel {
|
||||||
String reviveEndpoint: '/auth/token'}) async {
|
String reviveEndpoint: '/auth/token'}) async {
|
||||||
if (type == null) {
|
if (type == null) {
|
||||||
final url = '$basePath$reviveEndpoint';
|
final url = '$basePath$reviveEndpoint';
|
||||||
|
String token;
|
||||||
|
|
||||||
|
if (credentials is String)
|
||||||
|
token = credentials;
|
||||||
|
else if (credentials is Map && credentials.containsKey('token'))
|
||||||
|
token = credentials['token'];
|
||||||
|
|
||||||
|
if (token == null) {
|
||||||
|
throw new ArgumentError(
|
||||||
|
'If `type` is not set, a JWT is expected as the `credentials` argument.');
|
||||||
|
}
|
||||||
|
|
||||||
final response = await client.post(url, headers: {
|
final response = await client.post(url, headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': 'Bearer ${credentials['token']}'
|
'Authorization': 'Bearer $token'
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
|
||||||
if (_invalid(response)) {
|
if (_invalid(response)) {
|
||||||
throw failure(response);
|
throw failure(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
final json = JSON.decode(response.body);
|
final json = JSON.decode(response.body);
|
||||||
|
|
||||||
if (json is! Map ||
|
if (json is! Map ||
|
||||||
|
@ -96,6 +108,8 @@ abstract class BaseAngelClient extends Angel {
|
||||||
var r = new AngelAuthResult.fromMap(json);
|
var r = new AngelAuthResult.fromMap(json);
|
||||||
_onAuthenticated.add(r);
|
_onAuthenticated.add(r);
|
||||||
return r;
|
return r;
|
||||||
|
} on AngelHttpException {
|
||||||
|
rethrow;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
throw failure(response, error: e, stack: st);
|
throw failure(response, error: e, stack: st);
|
||||||
}
|
}
|
||||||
|
@ -110,11 +124,11 @@ abstract class BaseAngelClient extends Angel {
|
||||||
response = await client.post(url, headers: _writeHeaders);
|
response = await client.post(url, headers: _writeHeaders);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
if (_invalid(response)) {
|
if (_invalid(response)) {
|
||||||
throw failure(response);
|
throw failure(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
final json = JSON.decode(response.body);
|
final json = JSON.decode(response.body);
|
||||||
|
|
||||||
if (json is! Map ||
|
if (json is! Map ||
|
||||||
|
@ -128,6 +142,8 @@ abstract class BaseAngelClient extends Angel {
|
||||||
var r = new AngelAuthResult.fromMap(json);
|
var r = new AngelAuthResult.fromMap(json);
|
||||||
_onAuthenticated.add(r);
|
_onAuthenticated.add(r);
|
||||||
return r;
|
return r;
|
||||||
|
} on AngelHttpException {
|
||||||
|
rethrow;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
throw failure(response, error: e, stack: st);
|
throw failure(response, error: e, stack: st);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,17 @@
|
||||||
name: angel_client
|
name: angel_client
|
||||||
version: 1.0.7
|
version: 1.1.0-alpha
|
||||||
description: Client library for the Angel framework.
|
description: Client library for the Angel framework.
|
||||||
author: Tobe O <thosakwe@gmail.com>
|
author: Tobe O <thosakwe@gmail.com>
|
||||||
homepage: https://github.com/angel-dart/angel_client
|
homepage: https://github.com/angel-dart/angel_client
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=1.21.0"
|
sdk: ">=1.21.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
angel_framework: ">=1.0.0-dev <2.0.0"
|
angel_http_exception: ^1.0.0
|
||||||
http: ">= 0.11.3 < 0.12.0"
|
http: ">= 0.11.3 < 0.12.0"
|
||||||
json_god: ">=2.0.0-beta <3.0.0"
|
json_god: ">=2.0.0-beta <3.0.0"
|
||||||
merge_map: ">=1.0.0 <2.0.0"
|
merge_map: ">=1.0.0 <2.0.0"
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
angel_framework: ^1.1.0-alpha
|
||||||
|
angel_model: ^1.0.0
|
||||||
|
mock_request: ^1.0.0
|
||||||
test: ">= 0.12.13 < 0.13.0"
|
test: ">= 0.12.13 < 0.13.0"
|
||||||
|
|
94
test/all_test.dart
Normal file
94
test/all_test.dart
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:angel_client/angel_client.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
import 'common.dart';
|
||||||
|
|
||||||
|
main() {
|
||||||
|
var app = new MockAngel();
|
||||||
|
Service todoService = app.service('api/todos');
|
||||||
|
|
||||||
|
test('sets method,body,headers,path', () async {
|
||||||
|
await app.post('/post', headers: {'method': 'post'}, body: 'post');
|
||||||
|
expect(app.client.spec.method, 'POST');
|
||||||
|
expect(app.client.spec.path, '/post');
|
||||||
|
expect(app.client.spec.headers['method'], 'post');
|
||||||
|
expect(await read(app.client.spec.request.finalize()), 'post');
|
||||||
|
});
|
||||||
|
|
||||||
|
group('service methods', () {
|
||||||
|
test('index', () async {
|
||||||
|
await todoService.index();
|
||||||
|
expect(app.client.spec.method, 'GET');
|
||||||
|
expect(app.client.spec.path, '/api/todos');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read', () async {
|
||||||
|
await todoService.read('sleep');
|
||||||
|
expect(app.client.spec.method, 'GET');
|
||||||
|
expect(app.client.spec.path, '/api/todos/sleep');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create', () async {
|
||||||
|
await todoService.create({});
|
||||||
|
expect(app.client.spec.method, 'POST');
|
||||||
|
expect(app.client.spec.headers['content-type'],
|
||||||
|
startsWith('application/json'));
|
||||||
|
expect(app.client.spec.path, '/api/todos/');
|
||||||
|
expect(await read(app.client.spec.request.finalize()), '{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('modify', () async {
|
||||||
|
await todoService.modify('sleep', {});
|
||||||
|
expect(app.client.spec.method, 'PATCH');
|
||||||
|
expect(app.client.spec.headers['content-type'],
|
||||||
|
startsWith('application/json'));
|
||||||
|
expect(app.client.spec.path, '/api/todos/sleep');
|
||||||
|
expect(await read(app.client.spec.request.finalize()), '{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update', () async {
|
||||||
|
await todoService.update('sleep', {});
|
||||||
|
expect(app.client.spec.method, 'POST');
|
||||||
|
expect(app.client.spec.headers['content-type'],
|
||||||
|
startsWith('application/json'));
|
||||||
|
expect(app.client.spec.path, '/api/todos/sleep');
|
||||||
|
expect(await read(app.client.spec.request.finalize()), '{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remove', () async {
|
||||||
|
await todoService.remove('sleep');
|
||||||
|
expect(app.client.spec.method, 'DELETE');
|
||||||
|
expect(app.client.spec.path, '/api/todos/sleep');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('authentication', () {
|
||||||
|
test('no type, no token throws', () async {
|
||||||
|
expect(app.authenticate, throwsArgumentError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no type defaults to token', () async {
|
||||||
|
await app.authenticate(credentials: '<jwt>');
|
||||||
|
expect(app.client.spec.path, '/auth/token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets type', () async {
|
||||||
|
await app.authenticate(type: 'local');
|
||||||
|
expect(app.client.spec.path, '/auth/local');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('token sends headers', () async {
|
||||||
|
await app.authenticate(credentials: '<jwt>');
|
||||||
|
expect(app.client.spec.headers['authorization'], 'Bearer <jwt>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('credentials send right body', () async {
|
||||||
|
await app
|
||||||
|
.authenticate(type: 'local', credentials: {'username': 'password'});
|
||||||
|
expect(
|
||||||
|
await read(app.client.spec.request.finalize()),
|
||||||
|
JSON.encode({'username': 'password'}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,35 +0,0 @@
|
||||||
@TestOn('browser')
|
|
||||||
import 'package:angel_client/browser.dart';
|
|
||||||
import 'package:test/test.dart';
|
|
||||||
import 'for_browser_tests.dart';
|
|
||||||
|
|
||||||
main() {
|
|
||||||
test("list todos", () async {
|
|
||||||
var channel = spawnHybridCode(SERVER);
|
|
||||||
String url = await channel.stream.first;
|
|
||||||
print(url);
|
|
||||||
var app = new Rest(url);
|
|
||||||
var todoService = app.service("todos");
|
|
||||||
|
|
||||||
var todos = await todoService.index();
|
|
||||||
expect(todos, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('create todos', () async {
|
|
||||||
var channel = spawnHybridCode(SERVER);
|
|
||||||
String url = await channel.stream.first;
|
|
||||||
print(url);
|
|
||||||
var app = new Rest(url);
|
|
||||||
var todoService = app.service("todos");
|
|
||||||
|
|
||||||
var data = {'hello': 'world'};
|
|
||||||
var response = await todoService.create(data);
|
|
||||||
print('Created response: $response');
|
|
||||||
|
|
||||||
var todos = await todoService.index();
|
|
||||||
expect(todos, hasLength(1));
|
|
||||||
|
|
||||||
Map todo = todos.first;
|
|
||||||
expect(todo, equals(data));
|
|
||||||
});
|
|
||||||
}
|
|
67
test/common.dart
Normal file
67
test/common.dart
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:angel_client/base_angel_client.dart';
|
||||||
|
import 'package:http/src/base_client.dart' as http;
|
||||||
|
import 'package:http/src/base_request.dart' as http;
|
||||||
|
import 'package:http/src/streamed_response.dart' as http;
|
||||||
|
|
||||||
|
Future<String> read(Stream<List<int>> stream) =>
|
||||||
|
stream.transform(UTF8.decoder).join();
|
||||||
|
|
||||||
|
class MockAngel extends BaseAngelClient {
|
||||||
|
@override
|
||||||
|
final SpecClient client = new SpecClient();
|
||||||
|
|
||||||
|
MockAngel() : super(null, 'http://localhost:3000');
|
||||||
|
|
||||||
|
@override
|
||||||
|
authenticateViaPopup(String url, {String eventName: 'token'}) {
|
||||||
|
throw new UnsupportedError('Nope');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SpecClient extends http.BaseClient {
|
||||||
|
Spec _spec;
|
||||||
|
|
||||||
|
Spec get spec => _spec;
|
||||||
|
|
||||||
|
@override
|
||||||
|
send(http.BaseRequest request) {
|
||||||
|
_spec = new Spec(request, request.method, request.url.path, request.headers,
|
||||||
|
request.contentLength);
|
||||||
|
var data = {'text': 'Clean your room!', 'completed': true};
|
||||||
|
|
||||||
|
if (request.url.path.contains('auth'))
|
||||||
|
data = {
|
||||||
|
'token': '<jwt>',
|
||||||
|
'data': {'username': 'password'}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Future<http.StreamedResponse>.value(new http.StreamedResponse(
|
||||||
|
new Stream<List<int>>.fromIterable([UTF8.encode(JSON.encode(data))]),
|
||||||
|
200,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Spec {
|
||||||
|
final http.BaseRequest request;
|
||||||
|
final String method, path;
|
||||||
|
final Map<String, String> headers;
|
||||||
|
final int contentLength;
|
||||||
|
|
||||||
|
Spec(this.request, this.method, this.path, this.headers, this.contentLength);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return {
|
||||||
|
'method': method,
|
||||||
|
'path': path,
|
||||||
|
'headers': headers,
|
||||||
|
'content_length': contentLength,
|
||||||
|
}.toString();
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,30 +0,0 @@
|
||||||
const String SERVER = '''
|
|
||||||
import 'dart:io';
|
|
||||||
import "package:angel_framework/angel_framework.dart";
|
|
||||||
import "package:angel_framework/common.dart";
|
|
||||||
import 'package:stream_channel/stream_channel.dart';
|
|
||||||
|
|
||||||
hybridMain(StreamChannel channel) async {
|
|
||||||
var app = new Angel();
|
|
||||||
|
|
||||||
app.before.add((req, ResponseContext res) {
|
|
||||||
res.headers["Access-Control-Allow-Origin"] = "*";
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use("/todos", new TypedService<Todo>(new MapService()));
|
|
||||||
|
|
||||||
var server = await app.startServer(InternetAddress.LOOPBACK_IP_V4, 0);
|
|
||||||
|
|
||||||
print("Server up; listening at http://localhost:\${server.port}");
|
|
||||||
channel.sink.add('http://\${server.address.address}:\${server.port}');
|
|
||||||
}
|
|
||||||
|
|
||||||
class Todo extends Model {
|
|
||||||
String hello;
|
|
||||||
|
|
||||||
Todo({int id, this.hello}) {
|
|
||||||
this.id = id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
''';
|
|
|
@ -1,146 +0,0 @@
|
||||||
import 'dart:io';
|
|
||||||
import 'package:angel_client/io.dart' as client;
|
|
||||||
import 'package:angel_framework/angel_framework.dart' as server;
|
|
||||||
import 'package:json_god/json_god.dart' as god;
|
|
||||||
import 'package:test/test.dart';
|
|
||||||
import 'shared.dart';
|
|
||||||
|
|
||||||
main() {
|
|
||||||
group("rest", () {
|
|
||||||
server.Angel serverApp = new server.Angel();
|
|
||||||
server.HookedService serverPostcards;
|
|
||||||
client.Angel clientApp;
|
|
||||||
client.Service clientPostcards;
|
|
||||||
client.Service clientTypedPostcards;
|
|
||||||
HttpServer httpServer;
|
|
||||||
String url;
|
|
||||||
|
|
||||||
setUp(() async {
|
|
||||||
httpServer =
|
|
||||||
await serverApp.startServer(InternetAddress.LOOPBACK_IP_V4, 0);
|
|
||||||
url = "http://localhost:${httpServer.port}";
|
|
||||||
serverApp.use("/postcards", new server.TypedService<Postcard>(new server.MapService()));
|
|
||||||
serverPostcards = serverApp.service("postcards");
|
|
||||||
|
|
||||||
clientApp = new client.Rest(url);
|
|
||||||
clientPostcards = clientApp.service("postcards");
|
|
||||||
clientTypedPostcards = clientApp.service("postcards", type: Postcard);
|
|
||||||
});
|
|
||||||
|
|
||||||
tearDown(() async {
|
|
||||||
await httpServer.close(force: true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('plain requests', () async {
|
|
||||||
final response = await clientApp.get('/foo');
|
|
||||||
print(response.body);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("index", () async {
|
|
||||||
Map niagara = await clientPostcards.create(
|
|
||||||
new Postcard(location: "Niagara Falls", message: "Missing you!"));
|
|
||||||
Postcard niagaraFalls = new Postcard.fromJson(niagara);
|
|
||||||
|
|
||||||
print('Niagara Falls: ${niagaraFalls.toJson()}');
|
|
||||||
|
|
||||||
List indexed = await clientPostcards.index();
|
|
||||||
print(indexed);
|
|
||||||
|
|
||||||
expect(indexed.length, equals(1));
|
|
||||||
expect(indexed[0].keys.length, equals(3));
|
|
||||||
expect(indexed[0]['id'], equals(niagaraFalls.id));
|
|
||||||
expect(indexed[0]['location'], equals(niagaraFalls.location));
|
|
||||||
expect(indexed[0]['message'], equals(niagaraFalls.message));
|
|
||||||
|
|
||||||
Map l = await clientPostcards.create(new Postcard(
|
|
||||||
location: "The Louvre", message: "The Mona Lisa was watching me!"));
|
|
||||||
Postcard louvre = new Postcard.fromJson(l);
|
|
||||||
print(god.serialize(louvre));
|
|
||||||
List typedIndexed = await clientTypedPostcards.index();
|
|
||||||
expect(typedIndexed.length, equals(2));
|
|
||||||
expect(typedIndexed[1], equals(louvre));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("create/read", () async {
|
|
||||||
Map opry = {"location": "Grand Ole Opry", "message": "Yeehaw!"};
|
|
||||||
var created = await clientPostcards.create(opry);
|
|
||||||
print(created);
|
|
||||||
|
|
||||||
expect(created['id'] == null, equals(false));
|
|
||||||
expect(created["location"], equals(opry["location"]));
|
|
||||||
expect(created["message"], equals(opry["message"]));
|
|
||||||
|
|
||||||
var read = await clientPostcards.read(created['id']);
|
|
||||||
print(read);
|
|
||||||
expect(read['id'], equals(created['id']));
|
|
||||||
expect(read['location'], equals(created['location']));
|
|
||||||
expect(read['message'], equals(created['message']));
|
|
||||||
|
|
||||||
Postcard canyon = new Postcard(
|
|
||||||
location: "Grand Canyon",
|
|
||||||
message: "But did you REALLY experience it???");
|
|
||||||
created = await clientTypedPostcards.create(canyon);
|
|
||||||
print(god.serialize(created));
|
|
||||||
|
|
||||||
expect(created.location, equals(canyon.location));
|
|
||||||
expect(created.message, equals(canyon.message));
|
|
||||||
|
|
||||||
read = await clientTypedPostcards.read(created.id);
|
|
||||||
print(god.serialize(read));
|
|
||||||
expect(read.id, equals(created.id));
|
|
||||||
expect(read.location, equals(created.location));
|
|
||||||
expect(read.message, equals(created.message));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("modify/update", () async {
|
|
||||||
var innerPostcards =
|
|
||||||
serverPostcards.inner as server.TypedService<Postcard>;
|
|
||||||
print(innerPostcards.items);
|
|
||||||
Postcard mecca = await clientTypedPostcards
|
|
||||||
.create(new Postcard(location: "Mecca", message: "Pilgrimage"));
|
|
||||||
print(god.serialize(mecca));
|
|
||||||
|
|
||||||
// I'm too lazy to write the tests twice, because I know it works
|
|
||||||
// So I'll modify using the type-based client, and update using the
|
|
||||||
// map-based one
|
|
||||||
|
|
||||||
print("Postcards on server: " +
|
|
||||||
god.serialize(await serverPostcards.index()));
|
|
||||||
print("Postcards on client: " +
|
|
||||||
god.serialize(await clientPostcards.index()));
|
|
||||||
|
|
||||||
Postcard modified = await clientTypedPostcards
|
|
||||||
.modify(mecca.id, {"location": "Saudi Arabia"});
|
|
||||||
print(god.serialize(modified));
|
|
||||||
expect(modified.id, equals(mecca.id));
|
|
||||||
expect(modified.location, equals("Saudi Arabia"));
|
|
||||||
expect(modified.message, equals(mecca.message));
|
|
||||||
|
|
||||||
Map updated = await clientPostcards
|
|
||||||
.update(mecca.id, {"location": "Full", "message": "Overwrite"});
|
|
||||||
print(updated);
|
|
||||||
|
|
||||||
expect(updated.keys.length, equals(3));
|
|
||||||
expect(updated['id'], equals(mecca.id));
|
|
||||||
expect(updated['location'], equals("Full"));
|
|
||||||
expect(updated['message'], equals("Overwrite"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("remove", () async {
|
|
||||||
Postcard remove1 = await clientTypedPostcards
|
|
||||||
.create({"location": "remove", "message": "#1"});
|
|
||||||
Postcard remove2 = await clientTypedPostcards
|
|
||||||
.create({"location": "remove", "message": "#2"});
|
|
||||||
print(god.serialize([remove1, remove2]));
|
|
||||||
|
|
||||||
Map removed1 = await clientPostcards.remove(remove1.id);
|
|
||||||
expect(removed1.keys.length, equals(3));
|
|
||||||
expect(removed1['id'], equals(remove1.id));
|
|
||||||
expect(removed1['location'], equals(remove1.location));
|
|
||||||
expect(removed1['message'], equals(remove1.message));
|
|
||||||
|
|
||||||
Postcard removed2 = await clientTypedPostcards.remove(remove2.id);
|
|
||||||
expect(removed2, equals(remove2));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -1,4 +1,4 @@
|
||||||
import "package:angel_framework/common.dart";
|
import 'package:angel_model/angel_model.dart';
|
||||||
|
|
||||||
class Postcard extends Model {
|
class Postcard extends Model {
|
||||||
String location;
|
String location;
|
||||||
|
|
Loading…
Reference in a new issue