Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,23 @@ jobs:
if: ${{ hashFiles('test/**/*_test.dart') != '' }}
run: flutter test
- name: Codegen verify (only latest)
if: matrix.flutter == '3.32.x'
if: matrix.flutter == '3.32.x' && matrix.resolution == 'max'
run: |
set -euo pipefail
# build_runner が無い場合はスキップ
if ! grep -q 'build_runner:' pubspec.yaml; then
echo 'build_runner not found; skipping codegen verify'
exit 0
fi
flutter pub get
dart run build_runner build -d --build-filter="lib/**"
git diff --exit-code || (echo 'Codegen produced changes. Please commit generated files.' && exit 1)
# 生成物に差分があれば失敗
git update-index -q --refresh
if ! git diff --quiet --exit-code -- lib/; then
echo 'Codegen produced changes. Please commit generated files.'
git --no-pager diff -- lib/ | cat
exit 1
fi

example-android:
needs: changes
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.0.2-beta] - 2025-08-19

### Added
- Expose `MisskeyHttpClient.baseUrl` (original base, before `/api` normalization).
- Add `exceptionMapper` hook to `MisskeyHttpClient` to customize thrown exceptions.
- Add `loggerFn` (function-style logger) accepted by `MisskeyHttpClient` and adapt to existing `Logger` interface.
- `MetaClient.getMeta({bool refresh = false})` to force-refresh cache when needed.

### Changed
- Generalize `TokenProvider` to `FutureOr<String?> Function()` to support both sync/async token sources.

## [0.0.1-beta] - 2025-08-18

### Added
Expand Down
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ Misskey API Core is a Dart/Flutter package that provides the core building block
### Key Features

- HTTP foundation: base URL handling (/api), timeouts, idempotent retries (429/5xx/network), request/response logging (debug-only)
- Base URL exposure: access original base URL via `client.baseUrl` for derived services
- Auth token injection: automatically injects `i` into POST JSON bodies when `authRequired` is true
- Flexible token providers: support both sync and async token sources via `FutureOr<String?>`
- Unified error: normalize Misskey error response to `MisskeyApiException(statusCode/code/message)`
- Customizable error handling: map exceptions via `exceptionMapper` for unified error policies
- Flexible logging: use `loggerFn` for function-style logging or existing `Logger` interface
- Meta capability: `/api/meta` client with a tiny cache and `supports()` helper
- Meta refresh: force-refresh cached meta data with `getMeta(refresh: true)`
- JSON serialization: `json_serializable`-ready common model(s)

### Install
Expand All @@ -20,7 +25,7 @@ Add to `pubspec.yaml`:

```yaml
dependencies:
misskey_api_core: ^0.0.1-beta
misskey_api_core: ^0.0.2-beta
```

Then:
Expand All @@ -41,18 +46,24 @@ void main() async {
timeout: const Duration(seconds: 10),
enableLog: true, // logs only in debug mode
),
tokenProvider: () async => 'YOUR_TOKEN',
tokenProvider: () async => 'YOUR_TOKEN', // or sync: () => 'TOKEN'
);

// Fetch meta (no auth)
final meta = await MetaClient(client).getMeta();

// Force refresh meta data
final freshMeta = await MetaClient(client).getMeta(refresh: true);

// Example POST (token `i` will be injected automatically)
final res = await client.send<List<dynamic>>(
'/notes/timeline',
body: {'limit': 10},
options: const RequestOptions(idempotent: true),
);

// Access base URL for derived services (e.g., streaming)
final origin = client.baseUrl;
}
```

Expand All @@ -73,9 +84,14 @@ Misskey API Core は、Misskeyサーバーと連携するためのDart/Flutter
### 機能

- HTTP基盤: ベースURL(/api付与)・タイムアウト・冪等時の自動リトライ(429/5xx/ネットワーク)・デバッグ時のみログ
- ベースURL公開: `client.baseUrl` で元URLにアクセス(派生サービス用)
- 認証: POSTのJSONボディに `i` を自動注入(`authRequired`で制御)
- 柔軟なトークン供給: 同期・非同期両方に対応(`FutureOr<String?>`)
- 共通例外: Misskeyのエラーを `MisskeyApiException(statusCode/code/message)` に正規化
- カスタマイズ可能な例外処理: `exceptionMapper` で例外を一元変換
- 柔軟なログ出力: 関数ベースロガー(`loggerFn`)または既存Logger IF
- メタ/能力検出: `/api/meta` の取得と簡易キャッシュ、`supports()` ヘルパー
- メタ更新: `getMeta(refresh: true)` でキャッシュを強制更新
- JSONシリアライズ: `json_serializable`対応の共通モデル

### インストール
Expand All @@ -84,7 +100,7 @@ Misskey API Core は、Misskeyサーバーと連携するためのDart/Flutter

```yaml
dependencies:
misskey_api_core: ^0.0.1-beta
misskey_api_core: ^0.0.2-beta
```

実行:
Expand All @@ -104,18 +120,24 @@ final client = MisskeyHttpClient(
timeout: const Duration(seconds: 10),
enableLog: true, // デバッグ時のみ
),
tokenProvider: () async => 'YOUR_TOKEN',
tokenProvider: () async => 'YOUR_TOKEN', // または同期: () => 'TOKEN'
);

// 認証不要
final meta = await MetaClient(client).getMeta();

// メタデータを強制更新
final freshMeta = await MetaClient(client).getMeta(refresh: true);

// 読み取り系POST(`i`は自動注入)
final list = await client.send<List<dynamic>>(
'/notes/timeline',
body: {'limit': 10},
options: const RequestOptions(idempotent: true),
);

// 派生サービス用にベースURLにアクセス(例: ストリーミング)
final origin = client.baseUrl;
```

サンプルアプリ(`/example`)では、`misskey_auth` を使った認証、ノート投稿、ホームタイムライン、フォロー中/フォロワーの取得まで一通り確認できます。
Expand Down
5 changes: 3 additions & 2 deletions lib/misskey_api_core.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
export 'src/core/config/misskey_api_config.dart';
export 'src/core/auth/token_provider.dart';
export 'src/core/config/misskey_api_config.dart';
export 'src/core/error/misskey_api_exception.dart';
export 'src/core/http/misskey_http_client.dart';
export 'src/core/http/request_options.dart';
export 'src/core/error/misskey_api_exception.dart';
export 'src/core/logging/function_logger.dart';
export 'src/core/logging/logger.dart';
export 'src/meta/meta_client.dart';
export 'src/models/meta.dart';
4 changes: 3 additions & 1 deletion lib/src/core/auth/token_provider.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

/// 認可トークンを提供するための型
/// 同期/非同期のいずれにも対応できるように `FutureOr<String?>` を返す関数型を利用する想定
typedef TokenProvider = Future<String?> Function();
typedef TokenProvider = FutureOr<String?> Function();
20 changes: 15 additions & 5 deletions lib/src/core/http/misskey_http_client.dart
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import 'dart:async';

import 'package:dio/dio.dart';
import 'package:retry/retry.dart';
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:retry/retry.dart';

import '../auth/token_provider.dart';
import '../config/misskey_api_config.dart';
import '../error/misskey_api_exception.dart';
import '../logging/function_logger.dart';
import '../logging/logger.dart';
import 'request_options.dart' as ro;

Expand All @@ -15,15 +16,21 @@ class MisskeyHttpClient {
final MisskeyApiConfig config;
final TokenProvider? tokenProvider;
final Logger? logger;
final Object Function(Object error)? exceptionMapper;

/// 公開ベースURL(`/api` 付与前の元URL)
Uri get baseUrl => config.baseUrl;

late final Dio _dio;

MisskeyHttpClient({
required this.config,
this.tokenProvider,
this.logger,
Logger? logger,
this.exceptionMapper,
void Function(String level, String message)? loggerFn,
HttpClientAdapter? httpClientAdapter,
}) {
}) : logger = logger ?? (loggerFn != null ? FunctionLogger(loggerFn) : null) {
final baseOptions = BaseOptions(
baseUrl: _ensureApiBase(config.baseUrl).toString(),
connectTimeout: config.timeout,
Expand Down Expand Up @@ -65,6 +72,7 @@ class MisskeyHttpClient {
randomizationFactor: 0.25,
);

// リトライオプションに従い、Dioを使ってHTTPリクエストを送信し、必要に応じてリトライを行う処理
try {
final result = await r.retry(
() async {
Expand All @@ -88,9 +96,11 @@ class MisskeyHttpClient {
);
return result.data as T;
} on DioException catch (e) {
throw _mapDioError(e);
final err = _mapDioError(e);
throw exceptionMapper != null ? exceptionMapper!(err) : err;
} catch (e) {
throw MisskeyApiException(message: 'Unexpected error', raw: e);
final err = MisskeyApiException(message: 'Unexpected error', raw: e);
throw exceptionMapper != null ? exceptionMapper!(err) : err;
}
}

Expand Down
22 changes: 22 additions & 0 deletions lib/src/core/logging/function_logger.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import 'logger.dart';

/// 関数ベースのロガーを `Logger` IF へアダプトする軽量ラッパー
class FunctionLogger implements Logger {
final void Function(String level, String message) _fn;
const FunctionLogger(this._fn);

@override
void debug(String message) => _fn('debug', message);

@override
void info(String message) => _fn('info', message);

@override
void warn(String message) => _fn('warn', message);

@override
void error(String message, [Object? error, StackTrace? stackTrace]) {
final m = error == null ? message : '$message error=$error';
_fn('error', m);
}
}
12 changes: 8 additions & 4 deletions lib/src/meta/meta_client.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import '../core/http/request_options.dart';
import '../core/http/misskey_http_client.dart';
import '../core/http/request_options.dart';
import '../models/meta.dart';

/// `/api/meta` を取得するクライアント
Expand All @@ -9,8 +9,12 @@ class MetaClient {

MetaClient(this.http);

Future<Meta> getMeta() async {
if (_cached != null) return _cached!;
/// Misskeyサーバの `/api/meta` エンドポイントからメタ情報を取得する
///
/// [refresh] を `true` にするとキャッシュを無視して常に最新の情報を取得する
/// デフォルトでは、一度取得したメタ情報をキャッシュし、2回目以降はキャッシュを返す
Future<Meta> getMeta({bool refresh = false}) async {
if (!refresh && _cached != null) return _cached!;
final res = await http.send<Map<String, dynamic>>(
'/meta',
method: 'POST',
Expand All @@ -21,7 +25,7 @@ class MetaClient {
return _cached!;
}

/// 簡易な能力検出(キー存在で判定)
/// 簡易なサーバーの能力検出(キー存在で判定)
bool supports(String keyPath) {
final meta = _cached;
if (meta == null) return false;
Expand Down
3 changes: 2 additions & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name: misskey_api_core
description: "A library to make it easy to use the Misskey API in your Flutter and Dart apps."
version: 0.0.1-beta
version: 0.0.2-beta
homepage: https://librarylibrarian.com/
repository: https://github.com/LibraryLibrarian/misskey_api_core
issue_tracker: https://github.com/LibraryLibrarian/misskey_api_core/issues
Expand Down Expand Up @@ -35,6 +35,7 @@ dev_dependencies:
flutter_lints: ^6.0.0
build_runner: ^2.7.0
json_serializable: ^6.10.0
file: ^7.0.1

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
Expand Down
17 changes: 17 additions & 0 deletions test/http_baseurl_public_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:misskey_api_core/misskey_api_core.dart' as core;

void main() {
/// MisskeyHttpClientの `baseUrl` プロパティが、
/// `/api` 正規化前の元のベースURL(MisskeyApiConfigで指定したもの)を
/// 正しく公開していることを検証するテスト
///
/// - `baseUrl` には `/api` が付与されていない元のURLがそのまま格納されていること
test('MisskeyHttpClientのbaseUrlは元のURL(/api付与前)を公開する', () {
final base = Uri.parse('https://host.example/app');
final http = core.MisskeyHttpClient(
config: core.MisskeyApiConfig(baseUrl: base),
);
expect(http.baseUrl, base);
});
}
4 changes: 2 additions & 2 deletions test/http_error_mapping_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class _ErrorAdapter implements dio.HttpClientAdapter {
void main() {
/// `{ error: { code, message } }` 形式のMisskeyエラーが
/// `MisskeyApiException(code, message, statusCode)` に正規化されることを検証
test('maps Misskey error format with nested error object', () async {
test('Misskeyエラーフォーマットが正規化されることを検証', () async {
final client = core.MisskeyHttpClient(
config: core.MisskeyApiConfig(baseUrl: Uri.parse('https://example.com')),
httpClientAdapter: _ErrorAdapter(400, {
Expand All @@ -55,7 +55,7 @@ void main() {

/// `{ code, message }` のフラットなエラーフォーマットも
/// 同様に正規化されることを検証する
test('maps flat error format too', () async {
test('フラットなエラーフォーマットも正規化されることを検証', () async {
final client = core.MisskeyHttpClient(
config: core.MisskeyApiConfig(baseUrl: Uri.parse('https://example.com')),
httpClientAdapter: _ErrorAdapter(403, {
Expand Down
65 changes: 65 additions & 0 deletions test/http_exception_mapper_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import 'dart:convert';

import 'package:dio/dio.dart' as dio;
import 'package:flutter_test/flutter_test.dart';
import 'package:misskey_api_core/misskey_api_core.dart' as core;

class _ErrorAdapter implements dio.HttpClientAdapter {
final int statusCode;
_ErrorAdapter(this.statusCode);

@override
void close({bool force = false}) {}

@override
Future<dio.ResponseBody> fetch(
dio.RequestOptions options,
Stream<List<int>>? requestStream,
Future? cancelFuture,
) async {
return dio.ResponseBody.fromBytes(
utf8.encode(
jsonEncode(<String, dynamic>{
'error': {'code': 'SOME', 'message': 'oops'},
}),
),
statusCode,
headers: {
dio.Headers.contentTypeHeader: ['application/json'],
},
);
}
}

class MyUnifiedException implements Exception {
final String message;
const MyUnifiedException(this.message);
}

void main() {
/// `exceptionMapper` フックが MisskeyApiException をカスタム例外に変換できることを検証するテスト
///
/// - MisskeyHttpClient の `exceptionMapper` に、MisskeyApiException を受け取った際に
/// 独自の MyUnifiedException に変換する関数を指定する
/// - サーバーが 500 エラー(Misskey 形式のエラー)を返す状況を模擬し、
/// send() 実行時に MyUnifiedException が投げられることを確認する
test('exceptionMapperでMisskeyApiExceptionをカスタム例外に変換できる', () async {
final http = core.MisskeyHttpClient(
config: core.MisskeyApiConfig(baseUrl: Uri.parse('https://example.com')),
httpClientAdapter: _ErrorAdapter(500),
exceptionMapper: (Object error) {
if (error is core.MisskeyApiException) {
return MyUnifiedException(
'HTTP:${error.statusCode} ${error.message}',
);
}
return error;
},
);

expect(
() async => http.send('/x', body: const {}),
throwsA(isA<MyUnifiedException>()),
);
});
}
Loading
Loading