Skip to content

Repository files navigation

Bones_API

pub packageNull SafetyCodecovDart CIGitHub TagNew CommitsLast CommitsPull RequestsCode sizeLicense

Bones_API - A powerful API backend framework for Dart. It comes with a built-in HTTP Server, route handler, entity handler, SQL translator, and DB adapters.

Usage

A simple BTC-USD API example:

import'package:bones_api/bones_api_server.dart';
import'package:mercury_client/mercury_client.dart';
/// APIs are organized in modules:classMyBTCModuleextendsAPIModule {
MyBTCModule(APIRoot apiRoot) :super(apiRoot, 'btc');
/// The default route for not matching routes:@overrideString?get defaultRouteName =>'404';
/// A configuration property from `apiConfig`.Stringget notFoundMsg => apiConfig['not_found_msg'] ??'Unknown route!';
@overridevoidconfigure() {
routes.get('usd', (request) =>fetchBtcUsd());
routes.any('time', (request) =>APIResponse.ok(DateTime.now()));
routes.any('404', notFound);
}
/// A HTTP client for `fetchBtcUsd`:staticfinal coinDeskClient =HttpClient("https://api.coindesk.com/v1/bpi");
/// Fetches the BTS-USD price.Future<APIResponse<num>> fetchBtcUsd() async {
var response =await coinDeskClient.get('currentprice.json');
if (response.isNotOK) {
returnAPIResponse.notFound();
}
var btcUsd = response.json['bpi']['USD']['rate_float'] asnum?;
return btcUsd !=null?APIResponse.ok(btcUsd) :APIResponse.notFound();
}
/// Not found route (`404`):FutureOr<APIResponse> notFound(request) {
// The requested path:var path = request.path;
var body =''' <h1>404</h1><br> <b>PATH:<b> $path <p> <i>$notFoundMsg</i> ''';
// `APIResponse` with `content-type` and `cache-control`:returnAPIResponse.notFound(payload: body)
..payloadMimeType ='text/html'
..headers['cache-control'] ='no-store';
}
}
/// The `APIRoot` defines the API version and modules to use:classMyAPIextendsAPIRoot {
MyAPI({dynamic apiConfig}) :super('example', '1.0', apiConfig: apiConfig);
// Load the modules used by this API:@overrideSet<APIModule> loadModules() => {MyBTCModule(this)};
}
/// Starts an [APIServer] and calls the routes through [HttpClient]:voidmain() async {
// A JSON to configure the API:var apiConfigJson =''' {"not_found_msg": "This is 404!"} ''';
var api =MyAPI(apiConfig: apiConfigJson);
int? serverPort =awaitstartAPIServer(api);
var httpClient =HttpClient("http://localhost:$serverPort/");
var btcUsd = (await httpClient.get('/btc/usd')).bodyAsString;
print('BTC-USD: $btcUsd');
var time = (await httpClient.post('/btc/time')).bodyAsString;
print('TIME: $time');
var foo = (await httpClient.get('/btc/foo')).bodyAsString;
print('FOO:\n$foo');
awaitstopAPIServer();
}
latefinalAPIServer apiServer;
/// Starts the [APIServer] (HTTP Server) and returns the port./// - With Hot Reload if `--enable-vm-service` is passed to the Dart VM.Future<int?> startAPIServer(MyAPI api) async {
var serverPort =8088;
print('Starting APIServer...\n');
apiServer =APIServer(api, '*', serverPort, hotReload:true);
await apiServer.start();
print('\n$apiServer');
print('URL: ${apiServer.url}\n');
return serverPort;
}
/// Stops the [APIServer].Future<bool> stopAPIServer() async {
await apiServer.stop();
returntrue;
}

OUTPUT:

Starting APIServer...
2021-10-08 02:15:17.924328 [CONFIG] (main) APIHotReload > pkgConfigURL: ~/workspace/bones_api/.dart_tool/package_config.json
2021-10-08 02:15:17.959068 [CONFIG] (main) APIHotReload > Watching [~/workspace/bones_api] with [MacOSDirectoryWatcher]...
2021-10-08 02:15:18.185128 [INFO] (main) APIHotReload > Created HotReloader
2021-10-08 02:15:18.185624 [INFO] (main) APIHotReload > Enabled Hot Reload: true
2021-10-08 02:15:18.185852 [INFO] (main) APIServer > Started HTTP server: 0.0.0.0:8088
APIServer{ apiType: MyAPI, apiRoot: example[1.0]{btc}, address: 0.0.0.0, port: 8088, hotReload: true, started: true, stopped: false }
URL: http://0.0.0.0:8088/
BTC-USD: 53742.76
TIME: 2021-10-08 02:15:18.294076
FOO:
<h1>404</h1><br>
<b>PATH:<b> /btc/foo
<p>
<i>This is 404!</i>

CLI

You can use the built-in command-line interface (CLI) bones_api.

To activate it globally:

 $> dart pub global activate bones_api

Now you can use the CLI directly:

 $> bones_api --help

To serve an API project:

 $> bones_api serve --directory path/to/project --class MyAPIRoot --config api-prod.conf --port 80 --address 0.0.0.0 --build --hotreload --domain mydomain.com=/var/www

To create an API project file tree:

 $> bones_api create -o /path_to/workspace/foo_api -p project_name_dir=foo_api -p "project_name=Foo API" -p "project_description=API for Foo stuffs." -p homepage=http://foo.com

Hot Reload

APIServer supports Hot Reload when the Dart VM is running with --enable-vm-service:

voidmain() async {
var apiServer =APIServer(api, 'localhost', 8080, hotReload:true);
await apiServer.start();
}

The CLI bones_api, when called with --hotreload, will launch a new Dart VM with --enable-vm-service (if needed) to allow Hot Reload.

To serve an API project with Hot Reload enabled:

 $> bones_api serve --directory path/to/project --class MyAPIRoot --hotreload

Using Reflection

You can use the package reflection_factory to automate some declarations.

For example, you can map all routes in a class with one line of code:

File: module_account.dart:

import'package:bones_api/bones_api.dart';
import'package:reflection_factory/reflection_factory.dart';
// See Repositories sections below in this README:import'repositories.dart';
// The generated reflection code by `reflection_factory`:part'module_account.reflection.g.dart';
@EnableReflection()
classAccountModuleextendsAPIModule {
AccountModule(APIRoot apiRoot) :super(apiRoot, 'account');
finalAddressAPIRepository addressRepository =AddressAPIRepository();
finalAccountAPIRepository accountRepository =AccountAPIRepository();
@overridevoidconfigure() {
// Maps the POST routes by reflection of any method in this class// that returns `APIResponse` or accepts `APIRequest`.
routes.postFrom(reflection);
}
// The request parameters will be mapped to the correct// method parameter by name:Future<APIResponse> auth(String? email, String? password) async {
if (email ==null) {
returnAPIResponse.error(error:'Invalid parameters!');
}
if (password ==null) {
returnAPIResponse.unauthorized();
}
var sel =await accountRepository.selectAccountByEmail(email);
if (sel.isEmpty) {
returnAPIResponse.unauthorized();
}
var account = sel.first;
// The object `account` will be automatically converted// to JSON when the response is sent through HTTP.return account.checkPassword(password)
?APIResponse.ok(account)
:APIResponse.unauthorized();
}
}

Declaring Entities & Reflection

You can declare entities classes in portable Dart code (that also works in the Browser).

To easily enable toJSon and fromJson, just add @EnableReflection() to your entities.

File: entities.dart:

import'dart:convert';
import'package:crypto/crypto.dart';
import'package:reflection_factory/reflection_factory.dart';
part'entities.reflection.g.dart';
@EnableReflection()
classAccount {
int? id;
String email;
String passwordHash;
Address? address;
Account(this.email, String passwordOrHash, this.address, {this.id})
: passwordHash =hashPassword(passwordOrHash);
Account.create() :this('', '', null);
boolcheckPassword(String password) {
return passwordHash ==hashPassword(password);
}
staticfinalRegExp _regExpHEX =RegExp(r'ˆ(?:[0-9a-fA-F]{2})+$');
staticboolisHashedPassword(String password) {
return password.length ==64&& _regExpHEX.hasMatch(password);
}
staticStringhashPassword(String password) {
if (isHashedPassword(password)) {
return password;
}
var bytes = utf8.encode(password);
var digest = sha256.convert(bytes);
var hash = digest.toString();
return hash;
}
}
@EnableReflection()
classAddress {
int? id;
String countryCode;
String state;
String city;
String address1;
String address2;
String zipCode;
Address(this.countryCode, this.state, this.city, this.address1, this.address2,
this.zipCode,
{this.id});
Address.create() :this('', '', '', '', '', '');
}

See reflection_factory for more Reflection documentation.

Repositories & Database

To stored entities in Databases and manipulate them you can set up an EntityRepositoryProvider:

File: repositories.dart

import'package:bones_api/bones_api.dart';
// Import the PostgreSQL Adapter:import'package:bones_api/bones_api_adapter_postgre.dart';
// Import the above entities file:import'entities.dart';
/// The API `EntityRepositoryProvider`:classAPIEntityRepositoryProviderextendsEntityRepositoryProvider {
staticfinalAPIEntityRepositoryProvider _instance =APIEntityRepositoryProvider._();
// Singleton:factoryAPIEntityRepositoryProvider() => _instance;
// Returns the current `APIRoot`:APIRoot?get apiRoot =>APIRoot.get();
APIEntityRepositoryProvider._() {
// The current APIConfig:var apiConfig = apiRoot?.apiConfig;
var postgreAdapter =PostgreSQLAdapter.fromConfig(
apiConfig?['postgres'], // The connection configuration
parentRepositoryProvider:this,
);
// Join the `PostgreSQLAdapter` and the Address/Account// `EntityHandler` (from reflection) to set up an// `EntityRepository` that uses SQL:// Entity `Address` in table `address`:SQLEntityRepository<Address>(
postgreAdapter, 'address', Address$reflection().entityHandler);
// Entity `Account` in table `account`:SQLEntityRepository<Account>(
postgreAdapter, 'account', Account$reflection().entityHandler);
}
}
/// The [Address] APIRepository:classAddressAPIRepositoryextendsAPIRepository<Address> {
AddressAPIRepository() :super(provider:APIEntityRepositoryProvider());
/// Selects an [Address] by field `state`:FutureOr<Iterable<Address>> selectByState(String state) {
returnselectByQuery(' state == ? ', parameters: {'state': state});
}
}
/// The [Account] APIRepository:classAccountAPIRepositoryextendsAPIRepository<Account> {
AccountAPIRepository() :super(provider:APIEntityRepositoryProvider());
/// Selects an [Account] by field `email`:FutureOr<Iterable<Account>> selectAccountByEmail(String email) {
returnselectByQuery(' email == ? ', parameters: {'email': email});
}
/// Selects an Account by field `address` and sub-field `state`:FutureOr<Iterable<Account>> selectAccountByAddressState(String state) {
// This condition will be translated to a SQL with INNER JOIN (when using an SQLAdapter):returnselectByQuery(' address.state == ? ', parameters: [state]);
}
/// Selects a page of [Account]s by field `state` (`page` starts at 1):FutureOr<Iterable<Account>> selectAccountsPage(String state, int page) {
// `page` computes the offset from the page size, and implies `orderByID`,// so the pagination is stable// (translated to: ORDER BY <id> ASC LIMIT 20 OFFSET <(page - 1) * 20>).returnselectByQuery(
' address.state == ? ',
parameters: [state],
limit:20,
page: page,
);
}
/// Selects the 10 newest [Account]s (highest IDs first):FutureOr<Iterable<Account>> selectNewestAccounts() {
returnselectAll(
limit:10,
orderByID:true,
orderDirection:OrderDirection.descending,
);
}
}

Ordering and pagination

The select* methods accept limit, offset, page, orderByID and orderDirection:

  • orderByID orders by the table's ID column, resolved automatically from the table scheme — no column name to spell out.
  • A non-null offset turns orderByID on by default, since an offset without a stable order can return overlapping or missing rows across pages. Pass orderByID: false to opt out.
  • orderDirection is OrderDirection.ascending by default, and is ignored while the ordering is not active.
  • page is the 1-based ergonomic form of offset, using limit as the page size: page: 3, limit: 20 is offset: 40. It throws an ArgumentError if combined with an offset, if there is no positive limit to page by, or if it is below 1.

Paginated reads: EntityPagination

For reading a result page by page, paginateByQuery (and paginate / paginateAll) returns an EntityPagination, which keeps the pages it has already loaded:

var page = accountRepository.paginateByQuery(
' address.state == ? ',
parameters: ['NY'],
limit:20,
);
await page.loadNextPage(); // loads page 1
page[0]; // synchronous: already loaded
page[45]; // null: not loaded (never fetches)await page.getAt(45); // loads page 3 on demand
page.loadedPages; // [1, 3] — page 2 is a gap
page.maxLoadedIndex; // 59
page.totalLength; // null: the end is not known yetawait page.loadAll(); // fills the gaps and resolves the end
page.totalLength; // 57
page.finalPage; // 3

Pages are 1-based and entry indexes 0-based. Synchronous access (operator [], loadedEntities) never fetches — only the FutureOr methods (getAt, getPage, getRange, loadNextPage, loadAll, stream) do.

It is deliberately not a List: a paginated select does not know its length until it reaches the end, so totalLength is null until the final page is identified (isFinalPageResolved). Until then you still know maxLoadedIndex, maxKnownPage and which pages are loaded.

The optional onEvent hook reports what is being fetched, for progress reporting and logging:

var page = accountRepository.paginateByQuery(
' address.state == ? ',
parameters: ['NY'],
limit:20,
onEvent: (event) {
switch (event) {
caseEntityPaginationPageLoading(:var page):print('fetching page $page...');
caseEntityPaginationPageLoaded(:var page, :var entriesLength):print('page $page: $entriesLength entries');
caseEntityPaginationPageError(:var page, :var error):print('page $page failed: $error');
caseEntityPaginationPageSkipped(:var page, :var reason):print('page $page not fetched: ${reason.name}');
caseEntityPaginationEnd(:var totalLength):print('done: $totalLength entries');
caseEntityPaginationReset(:var discardedPages):print('discarded ${discardedPages.length} pages');
}
},
);

Events are delivered synchronously and in order, so the sequence is meaningful even for a synchronous page loader. To consume them as a Stream instead, forward them: onEvent: myEventStream.add.

The config file used above:

File: api-local.yaml

postgres:
database: yourdbusername: postgrespassword: 123456

SQLAdapter

To use a SQL database with your EntityRepository you need a SQLAdapter:

  • DBPostgreSQLAdapter: a PostgreSQL adapter. Import: package:bones_api/bones_api_db_postgre.dart
  • DBMySQLAdapter: a MySQL adapter. Import: package:bones_api/bones_api_db_mysql.dart
  • DBSQLiteAdapter: an embedded SQLite adapter, for a database file or an in-memory database. Needs no server and no native library: the sqlite3 package bundles SQLite itself. Import: package:bones_api/bones_api_db_sqlite.dart
  • DBSQLMemoryAdapter: a portable SQLAdapter that stores entities in memory.

The SQLAdapter is responsible to connect to the database, manage the connection pool and also to adjust the generated SQLs to the correct dialect.

Example of a SQLite configuration:

db:
sqlite:
path: /var/lib/myapp/db.sqlitegenerateTables: true

Use memory: true (or path: ':memory:') for an in-memory database.

Bones_UI

See also the package Bones_UI, a simple and easy Web User Interface Framework for Dart.

Features and bugs

Please file feature requests and bugs at the issue tracker.

Contribution

Any help from the open-source community is always welcome and needed:

  • Found an issue?
    • Please fill a bug report with details.
  • Wish a feature?
    • Open a feature request with use cases.
  • Are you using and liking the project?
    • Promote the project: create an article, do a post or make a donation.
  • Are you a developer?
    • Fix a bug and send a pull request.
    • Implement a new feature.
    • Improve the Unit Tests.
  • Have you already helped in any way?
    • Many thanks from me, the contributors and everybody that uses this project!

Author

Graciliano M. Passos: gmpassos@GitHub.

License

Artistic License - Version 2.0

About

Simple and easy API framework, with routes and HTTP Server.

Resources

Stars

20 stars

Watchers

1 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages