Baselyra Docs

Flutter and Dart

There is no official Dart client. The API is plain HTTP, so package:http plus package:web_socket_channel for realtime is the whole story. What follows is a complete client you can paste into a project and extend.

Dependencies

dependencies:
  http: ^1.2.0
  web_socket_channel: ^3.0.0
  shared_preferences: ^2.3.0

The client

import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:web_socket_channel/web_socket_channel.dart';

class BaselyraException implements Exception {
  BaselyraException(this.code, this.message, this.status);
  final String code;
  final String message;
  final int status;
  @override
  String toString() => 'Baselyra $status $code: $message';
}

class Baselyra {
  Baselyra({required this.url, required this.anonKey});

  final String url;
  final String anonKey;
  String? _accessToken;
  String? _refreshToken;

  Map<String, String> get _headers => {
        'apikey': anonKey,
        'authorization': 'Bearer ${_accessToken ?? anonKey}',
        'content-type': 'application/json',
      };

  dynamic _decode(http.Response res) {
    final body = res.body.isEmpty ? null : jsonDecode(res.body);
    if (res.statusCode >= 400) {
      final error = (body is Map && body['error'] is Map) ? body['error'] as Map : const {};
      throw BaselyraException(
        (error['code'] ?? 'http_error').toString(),
        (error['message'] ?? res.reasonPhrase ?? 'Request failed').toString(),
        res.statusCode,
      );
    }
    return body;
  }

  // ---- auth ----

  Future<void> signIn(String email, String password) async {
    final res = await http.post(
      Uri.parse('$url/auth/v1/token?grant_type=password'),
      headers: _headers,
      body: jsonEncode({'email': email, 'password': password}),
    );
    await _store(_decode(res) as Map<String, dynamic>);
  }

  Future<void> signUp(String email, String password, {Map<String, dynamic>? data}) async {
    final res = await http.post(
      Uri.parse('$url/auth/v1/signup'),
      headers: _headers,
      body: jsonEncode({'email': email, 'password': password, if (data != null) 'data': data}),
    );
    final body = _decode(res) as Map<String, dynamic>;
    // Null when the instance confirms email addresses — the user must click the link.
    if (body['session'] != null) await _store(body['session'] as Map<String, dynamic>);
  }

  Future<void> signOut() async {
    await http.post(Uri.parse('$url/auth/v1/logout'), headers: _headers);
    _accessToken = null;
    _refreshToken = null;
    final prefs = await SharedPreferences.getInstance();
    await prefs.remove('bl_refresh_token');
  }

  /// Access tokens last an hour; call this at startup to trade the stored
  /// refresh token for a fresh pair. A refresh token is single-use, so the new
  /// one MUST be stored or the next launch signs the user out.
  Future<bool> restore() async {
    final prefs = await SharedPreferences.getInstance();
    final stored = prefs.getString('bl_refresh_token');
    if (stored == null) return false;
    final res = await http.post(
      Uri.parse('$url/auth/v1/token?grant_type=refresh_token'),
      headers: {'apikey': anonKey, 'content-type': 'application/json'},
      body: jsonEncode({'refresh_token': stored}),
    );
    if (res.statusCode >= 400) {
      await prefs.remove('bl_refresh_token');
      return false;
    }
    await _store(jsonDecode(res.body) as Map<String, dynamic>);
    return true;
  }

  Future<void> _store(Map<String, dynamic> session) async {
    _accessToken = session['access_token'] as String?;
    _refreshToken = session['refresh_token'] as String?;
    final prefs = await SharedPreferences.getInstance();
    if (_refreshToken != null) await prefs.setString('bl_refresh_token', _refreshToken!);
  }

  // ---- data ----

  Future<List<dynamic>> select(String table, {Map<String, String> query = const {}}) async {
    final uri = Uri.parse('$url/rest/v1/$table').replace(queryParameters: query);
    return _decode(await http.get(uri, headers: _headers)) as List<dynamic>;
  }

  Future<List<dynamic>> insert(String table, Map<String, dynamic> row) async {
    final res = await http.post(
      Uri.parse('$url/rest/v1/$table'),
      headers: {..._headers, 'Prefer': 'return=representation'},
      body: jsonEncode(row),
    );
    return _decode(res) as List<dynamic>;
  }

  Future<List<dynamic>> update(
      String table, Map<String, String> filters, Map<String, dynamic> patch) async {
    if (filters.isEmpty) throw ArgumentError('a filter is required: an unfiltered PATCH rewrites the table');
    final uri = Uri.parse('$url/rest/v1/$table').replace(queryParameters: filters);
    final res = await http.patch(
      uri,
      headers: {..._headers, 'Prefer': 'return=representation'},
      body: jsonEncode(patch),
    );
    return _decode(res) as List<dynamic>;
  }

  /// The server refuses an unfiltered delete, so `filters` must not be empty.
  Future<void> delete(String table, Map<String, String> filters) async {
    if (filters.isEmpty) throw ArgumentError('a filter is required: an unfiltered DELETE empties the table');
    final uri = Uri.parse('$url/rest/v1/$table').replace(queryParameters: filters);
    _decode(await http.delete(uri, headers: _headers));
  }

  Future<dynamic> rpc(String fn, Map<String, dynamic> args) async {
    final res = await http.post(
      Uri.parse('$url/rest/v1/rpc/$fn'),
      headers: _headers,
      body: jsonEncode(args),
    );
    return _decode(res);
  }

  // ---- storage ----

  Future<Map<String, dynamic>> upload(
      String bucket, String key, List<int> bytes, String mime) async {
    final request = http.MultipartRequest('POST', Uri.parse('$url/storage/v1/object/$bucket/$key'))
      ..headers['authorization'] = 'Bearer ${_accessToken ?? anonKey}'
      ..files.add(http.MultipartFile.fromBytes('file', bytes, filename: key.split('/').last));
    final res = await http.Response.fromStream(await request.send());
    return _decode(res) as Map<String, dynamic>;
  }

  String publicUrl(String bucket, String key) => '$url/storage/v1/object/public/$bucket/$key';

  Future<String> signedUrl(String bucket, String key, {int expiresIn = 3600}) async {
    final res = await http.post(
      Uri.parse('$url/storage/v1/object/sign/$bucket/$key'),
      headers: _headers,
      body: jsonEncode({'expiresIn': expiresIn}),
    );
    return (_decode(res) as Map<String, dynamic>)['url'] as String;
  }

  // ---- realtime ----

  /// One socket, one channel. Cancel the returned subscription on dispose.
  Stream<Map<String, dynamic>> subscribe(String channel, {String? filter}) {
    final wsUrl = url.replaceFirst(RegExp(r'^http'), 'ws');
    final socket = WebSocketChannel.connect(
      Uri.parse('$wsUrl/realtime/v1?apikey=${_accessToken ?? anonKey}'),
    );
    socket.sink.add(jsonEncode({
      'type': 'subscribe',
      'channel': channel,
      if (filter != null) 'filter': filter,
    }));
    return socket.stream
        .map((event) => jsonDecode(event as String) as Map<String, dynamic>)
        .where((frame) => frame['type'] == 'postgres_changes' || frame['type'] == 'broadcast');
  }
}

Using it

final bl = Baselyra(url: 'https://api.example.com', anonKey: '<anon key>');

await bl.restore();                                   // at startup
await bl.signIn('ada@example.com', 'correct-horse-battery');

final notes = await bl.select('notes', query: {
  'select': 'id,title',
  'order': 'created_at.desc',
  'limit': '20',
});

await bl.insert('notes', {'title': 'From Flutter'});

final sub = bl.subscribe('public:notes', filter: 'archived=is.false').listen((frame) {
  debugPrint('${frame['event']} ${frame['new']}');
});
// later: await sub.cancel();

Things worth knowing on mobile

  • Store the rotated refresh token. Every refresh returns a new one and retires the old. Replaying a spent token revokes the whole chain and the user is signed out — which is exactly what an app that forgets to save the new value does on its second launch.
  • Refresh at startup, not on a 401. A stored token is usually stale by the next launch, and a failure-driven refresh means every call site has to know how to replay itself.
  • The OS suspends sockets in the background. Re-subscribe and refetch on resume rather than trusting the change feed to have caught everything while the app was asleep.
  • Filters are query parameters, so Uri.replace(queryParameters:) does the encoding for you. A literal + in a value must be encoded or it decodes to a space.
  • Consider flutter_secure_storage instead of SharedPreferences for the refresh token — it is a 30-day credential.

Failure modes

What you seeWhyFix
Signed out on the second launchThe rotated refresh token was not storedStore the new one on every refresh
400 invalid_credentials for a real accountSame message for a wrong password and an unknown addressShow it verbatim
signUp returns no sessionAUTH_CONFIRM_EMAIL is onThe user has to click the link first
An empty list where the Studio shows rowsRLS admits nothing for this roleWrite a policy
The realtime stream never emitsThe table has no trigger, or a proxy ate the Upgrade headerselect baselyra.enable_realtime('public.notes'), then the 101 test
ArgumentError: a filter is requiredThe guard above firedPass a filter — the server would have refused it anyway

Edit this page Report a problem

Esc
navigate open Esc close