import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; import 'package:meta/meta.dart'; import 'database.dart'; import 'info.dart'; /// Credentials for a Musicus account. class MusicusAccountCredentials { /// The user's username. final String username; /// The user's password. final String password; MusicusAccountCredentials({ this.username, this.password, }); } /// Additional information on a Musicus account. class MusicusAccountDetails { /// An optional email address. final String email; MusicusAccountDetails({ this.email, }); } /// A simple http client for the Musicus server. class MusicusClient { /// URI scheme to use for the connection. /// /// This will be used as the scheme parameter when creating Uri objects. final String scheme; /// The host name of the Musicus server to connect to. /// /// This will be used as the host parameter when creating Uri objects. final String host; /// This will be used as the port parameter when creating Uri objects. final int port; /// Base path to the root location of the Musicus API. final String basePath; MusicusAccountCredentials _credentials; /// Account credentials for login. /// /// If this is null, unauthorized requests will fail. MusicusAccountCredentials get credentials => _credentials; set credentials(MusicusAccountCredentials credentials) { _credentials = credentials; _token = null; } final _client = http.Client(); /// The last retrieved access token. /// /// If this is null, a new token should be retrieved using [login] if needed. String _token; MusicusClient({ this.scheme = 'https', @required this.host, this.port = 443, this.basePath, MusicusAccountCredentials credentials, }) : assert(scheme != null), assert(port != null), assert(host != null), _credentials = credentials; /// Create an URI using member variables and parameters. Uri createUri({ @required String path, Map params, }) { return Uri( scheme: scheme, host: host, port: port, path: basePath != null ? basePath + path : path, queryParameters: params, ); } /// Create a new Musicus account. /// /// The email address is optional. This will return true, if the action was /// successful. In that case, the new credentials will automatically be /// stored as under [credentials] and used for subsequent requests. Future registerAccount({ @required String username, @required String password, String email, }) async { final response = await _client.post( createUri( path: '/account/register', ), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'username': username, 'password': password, 'email': email, }), ); if (response.statusCode == HttpStatus.ok) { _credentials = MusicusAccountCredentials( username: username, password: password, ); _token = null; return true; } else { return false; } } /// Get the current account details. Future getAccountDetails() async { assert(_credentials != null); final response = await _authorized( 'GET', createUri(path: '/account/details'), ); if (response.statusCode == HttpStatus.ok) { final json = jsonDecode(response.body); return MusicusAccountDetails( email: json['email'], ); } else { return null; } } /// Change the account details for the currently used user account. /// /// If a parameter is null, it will not be changed. This will throw a /// [MusicusLoginFailedException] if the account doesn't exist or the old /// password was wrong. Future updateAccount({ String newEmail, String newPassword, }) async { assert(_credentials != null); final response = await _client.post( createUri(path: '/account/details'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'username': _credentials.username, 'password': _credentials.password, 'newEmail': newEmail, 'newPassword': newPassword, }), ); if (response.statusCode != HttpStatus.ok) { throw MusicusLoginFailedException(); } } /// Delete the currently used Musicus account. /// /// This will throw a [MusicusLoginFailedException] if the user doesn't exist /// or the password was wrong. Future deleteAccount() async { assert(_credentials != null); final response = await _client.post( createUri(path: '/account/delete'), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'username': _credentials.username, 'password': _credentials.password, }), ); if (response.statusCode == HttpStatus.ok) { _credentials = null; _token = null; } else { throw MusicusLoginFailedException(); } } /// Retrieve an access token for the current user. /// /// This will be called automatically, when the client calls a method that /// requires it. If the login failed, a [MusicusLoginFailedException] will be /// thrown. Future login() async { assert(_credentials != null); final response = await _client.post( createUri( path: '/account/login', ), headers: {'Content-Type': 'application/json'}, body: jsonEncode({ 'username': _credentials.username, 'password': _credentials.password, }), ); if (response.statusCode == HttpStatus.ok) { _token = response.body; } else { throw MusicusLoginFailedException(); } } /// Make a request with authorization. /// /// This will ensure, that the request will be made with a valid /// authorization header. If [user] is null, this will throw a /// [MusicusNotLoggedInException]. If it is neccessary, this will login the /// user and throw a [MusicusLoginFailedException] if that failed. If the /// user is not authorized to perform the requested action, this will throw /// a [MusicusNotAuthorizedException]. Future _authorized(String method, Uri uri, {Map headers, String body}) async { if (_credentials != null) { Future _request() async { final request = http.Request(method, uri); if (headers != null) { request.headers.addAll(headers); } request.headers['Authorization'] = 'Bearer $_token'; if (body != null) { request.body = body; } return await http.Response.fromStream(await _client.send(request)); } http.Response response; if (_token != null) { response = await _request(); if (response.statusCode == HttpStatus.unauthorized) { await login(); response = await _request(); } } else { await login(); response = await _request(); } if (response.statusCode == HttpStatus.forbidden) { throw MusicusNotAuthorizedException(); } else { return response; } } else { throw MusicusNotLoggedInException(); } } /// Get a list of persons. /// /// You can get another page using the [page] parameter. If a non empty /// [search] string is provided, the persons will get filtered based on that /// string. Future> getPersons([int page, String search]) async { final params = {}; if (page != null) { params['p'] = page.toString(); } if (search != null) { params['s'] = search; } final response = await _client.get(createUri( path: '/persons', params: params, )); final json = jsonDecode(response.body); return json .map((j) => Person.fromJson(j).copyWith( sync: true, synced: true, )) .toList(); } /// Get a person by ID. Future getPerson(int id) async { final response = await _client.get(createUri( path: '/persons/$id', )); final json = jsonDecode(response.body); return Person.fromJson(json).copyWith( sync: true, synced: true, ); } /// Delete a person by ID. Future deletePerson(int id) async { await _authorized( 'DELETE', createUri( path: '/persons/$id', ), ); } /// Create or update a person. /// /// Returns true, if the operation was successful. Future putPerson(Person person) async { final response = await _authorized( 'PUT', createUri( path: '/persons/${person.id}', ), headers: {'Content-Type': 'application/json'}, body: jsonEncode(person.toJson()), ); return response.statusCode == HttpStatus.ok; } /// Get a list of instruments. /// /// You can get another page using the [page] parameter. If a non empty /// [search] string is provided, the results will get filtered based on that /// string. Future> getInstruments([int page, String search]) async { final params = {}; if (page != null) { params['p'] = page.toString(); } if (search != null) { params['s'] = search; } final response = await _client.get(createUri( path: '/instruments', params: params, )); final json = jsonDecode(response.body); return json .map((j) => Instrument.fromJson(j).copyWith( sync: true, synced: true, )) .toList(); } /// Get an instrument by ID. Future getInstrument(int id) async { final response = await _client.get(createUri( path: '/instruments/$id', )); final json = jsonDecode(response.body); return Instrument.fromJson(json).copyWith( sync: true, synced: true, ); } /// Create or update an instrument. /// /// Returns true, if the operation was successful. Future putInstrument(Instrument instrument) async { final response = await _authorized( 'PUT', createUri( path: '/instruments/${instrument.id}', ), headers: {'Content-Type': 'application/json'}, body: jsonEncode(instrument.toJson()), ); return response.statusCode == HttpStatus.ok; } /// Delete an instrument by ID. Future deleteInstrument(int id) async { await _authorized( 'DELETE', createUri( path: '/instruments/$id', ), ); } /// Get a list of works written by the person with the ID [personId]. /// /// You can get another page using the [page] parameter. If a non empty /// [search] string is provided, the results will get filtered based on that /// string. Future> getWorks(int personId, [int page, String search]) async { final params = {}; if (page != null) { params['p'] = page.toString(); } if (search != null) { params['s'] = search; } final response = await _client.get(createUri( path: '/persons/$personId/works', params: params, )); final json = jsonDecode(response.body); return json.map((j) => WorkInfo.fromJson(j, sync: true)).toList(); } /// Get a work by ID. Future getWork(int id) async { final response = await _client.get(createUri( path: '/works/$id', )); final json = jsonDecode(response.body); return WorkInfo.fromJson(json, sync: true); } /// Delete a work by ID. Future deleteWork(int id) async { await _authorized( 'DELETE', createUri( path: '/works/$id', ), ); } /// Get a list of recordings of the work with the ID [workId]. /// /// You can get another page using the [page] parameter. Future> getRecordings(int workId, [int page]) async { final params = {}; if (page != null) { params['p'] = page.toString(); } final response = await _client.get(createUri( path: '/works/$workId/recordings', params: params, )); final json = jsonDecode(response.body); return json .map((j) => RecordingInfo.fromJson(j, sync: true)) .toList(); } /// Create or update a work. /// /// Returns true, if the operation was successful. Future putWork(WorkInfo workInfo) async { final response = await _authorized( 'PUT', createUri( path: '/works/${workInfo.work.id}', ), headers: {'Content-Type': 'application/json'}, body: jsonEncode(workInfo.toJson()), ); return response.statusCode == HttpStatus.ok; } /// Get a list of ensembles. /// /// You can get another page using the [page] parameter. If a non empty /// [search] string is provided, the results will get filtered based on that /// string. Future> getEnsembles([int page, String search]) async { final params = {}; if (page != null) { params['p'] = page.toString(); } if (search != null) { params['s'] = search; } final response = await _client.get(createUri( path: '/ensembles', params: params, )); final json = jsonDecode(response.body); return json .map((j) => Ensemble.fromJson(j).copyWith( sync: true, synced: true, )) .toList(); } /// Get an ensemble by ID. Future getEnsemble(int id) async { final response = await _client.get(createUri( path: '/ensembles/$id', )); final json = jsonDecode(response.body); return Ensemble.fromJson(json).copyWith( sync: true, synced: true, ); } /// Create or update an ensemble. /// /// Returns true, if the operation was successful. Future putEnsemble(Ensemble ensemble) async { final response = await _authorized( 'PUT', createUri( path: '/ensembles/${ensemble.id}', ), headers: {'Content-Type': 'application/json'}, body: jsonEncode(ensemble.toJson()), ); return response.statusCode == HttpStatus.ok; } /// Delete an ensemble by ID. Future deleteEnsemble(int id) async { await _authorized( 'DELETE', createUri( path: '/ensembles/$id', ), ); } /// Get a recording by ID. Future getRecording(int id) async { final response = await _client.get(createUri( path: '/recordings/$id', )); final json = jsonDecode(response.body); return RecordingInfo.fromJson(json, sync: true); } /// Create or update a recording. /// /// Returns true, if the operation was successful. Future putRecording(RecordingInfo recordingInfo) async { final response = await _authorized( 'PUT', createUri( path: '/recordings/${recordingInfo.recording.id}', ), headers: {'Content-Type': 'application/json'}, body: jsonEncode(recordingInfo.toJson()), ); return response.statusCode == HttpStatus.ok; } /// Delete a recording by ID. Future deleteRecording(int id) async { await _authorized( 'DELETE', createUri( path: '/recordings/$id', ), ); } /// Close the internal http client. void dispose() { _client.close(); } } class MusicusLoginFailedException implements Exception { MusicusLoginFailedException(); String toString() => 'MusicusLoginFailedException: The username or password ' 'was wrong.'; } class MusicusNotLoggedInException implements Exception { MusicusNotLoggedInException(); String toString() => 'MusicusNotLoggedInException: The user must be logged in to perform ' 'this action.'; } class MusicusNotAuthorizedException implements Exception { MusicusNotAuthorizedException(); String toString() => 'MusicusNotAuthorizedException: The logged in user is not allowed to ' 'perform this action.'; }