diff --git a/examples/flutter_web/pubspec.lock b/examples/flutter_web/pubspec.lock index ee545302..a9778705 100644 --- a/examples/flutter_web/pubspec.lock +++ b/examples/flutter_web/pubspec.lock @@ -5,42 +5,42 @@ packages: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.12.0" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" collection: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" cupertino_icons: dependency: "direct main" description: @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" flutter: dependency: "direct main" description: flutter @@ -79,18 +79,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: @@ -111,10 +111,10 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -127,71 +127,71 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.16.0" path: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" stack_trace: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.4" vector_math: dependency: transitive description: @@ -204,10 +204,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "14.2.4" + version: "15.0.0" sdks: - dart: ">=3.3.0 <4.0.0" + dart: ">=3.7.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54" diff --git a/examples/functions/.gitignore b/examples/functions/.gitignore new file mode 100644 index 00000000..3a857904 --- /dev/null +++ b/examples/functions/.gitignore @@ -0,0 +1,3 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ diff --git a/examples/functions/CHANGELOG.md b/examples/functions/CHANGELOG.md new file mode 100644 index 00000000..effe43c8 --- /dev/null +++ b/examples/functions/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/examples/functions/README.md b/examples/functions/README.md new file mode 100644 index 00000000..3816eca3 --- /dev/null +++ b/examples/functions/README.md @@ -0,0 +1,2 @@ +A sample command-line application with an entrypoint in `bin/`, library code +in `lib/`, and example unit test in `test/`. diff --git a/examples/functions/analysis_options.yaml b/examples/functions/analysis_options.yaml new file mode 100644 index 00000000..dee8927a --- /dev/null +++ b/examples/functions/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/examples/functions/bin/functions.dart b/examples/functions/bin/functions.dart new file mode 100644 index 00000000..5f3dcadb --- /dev/null +++ b/examples/functions/bin/functions.dart @@ -0,0 +1,5 @@ +import 'package:functions/functions.dart' as functions; + +void main(List arguments) { + print('Hello world: ${functions.calculate()}!'); +} diff --git a/examples/functions/lib/api/foo.dart b/examples/functions/lib/api/foo.dart new file mode 100644 index 00000000..e69de29b diff --git a/examples/functions/lib/api/users.dart b/examples/functions/lib/api/users.dart new file mode 100644 index 00000000..082c0424 --- /dev/null +++ b/examples/functions/lib/api/users.dart @@ -0,0 +1,65 @@ +// import 'package:functions/models.dart'; +// import 'package:globe_functions/globe_functions.dart'; +// import 'package:shelf/shelf.dart'; +// import 'package:shelf_cors_headers/shelf_cors_headers.dart'; + +// const corsMiddleware = corsHeaders(); + +// final p = Pipeline(); +// final withCors = p.addMiddleware(corsMiddleware); +// final withAuth = p.addMiddleware(authMiddleware); + +// @RpcFunction() +// User? example1(String name, {Address? address}) { +// return User(name: 'John', address: address); +// } + +// @HttpFunction('/users') +// Future example2() async { +// return User(name: 'John'); +// } + +// @HttpFunction('/users/me') +// DateTime example3() { +// return DateTime.now(); +// } + +// @HttpFunction('/users/me2') +// Future example4() async { +// return 'Hello'; +// } + +// const Register { +// const Register(); +// } + +// const register = Register(); + + +// // api/users.dart + +// // Some middleware pipelines. +// final p = Pipeline(); +// final withCors = p.addMiddleware(corsMiddleware); +// final withAuth = p.addMiddleware(authMiddleware); + +// @register +// final app = Functions() +// .rpc(example1, pipeline: withCors) +// .cron('* * * * *', example2) +// .get('/users', example2) +// .post('/users/me', example3) +// .patch('/users/me2', example4) +// .any('/users/me3', example5); + +// // client generated from this +// // void main() async { +// // final client = Client('https://localhost:8080'); +// // await client.rpc.example1('John'); // Future +// // await client.users.example2.invoke(); // Future +// // await client.http.get('/users'); // Future +// // // etc +// // } + + + diff --git a/examples/functions/lib/api/users/details.dart b/examples/functions/lib/api/users/details.dart new file mode 100644 index 00000000..b847acdd --- /dev/null +++ b/examples/functions/lib/api/users/details.dart @@ -0,0 +1,37 @@ +// // import 'package:functions/models.dart'; +// // import 'package:globe_functions/globe_functions.dart'; + +// import 'package:functions/functions.dart'; +// import 'package:globe_functions/globe_functions.dart'; + +// @RpcFunction() +// Future get(String name, {Person? user}) async { +// return Person('elliot', 30); +// } + +// // @HttpFunction() +// // Future getNumer(String name, {User? user, DateTime? date}) async { +// // return 3; +// // } + +// // @HttpFunction() +// // Future updateUserDetails( +// // String userId, +// // List roles, [ +// // String? department = 'General', +// // int accessLevel = 1, +// // ]) async { +// // return User(name: userId); +// // } + +// // @HttpFunction() +// // Future createUserProfile( +// // String name, { +// // required List permissions, +// // String? title, +// // Map metadata = const {}, +// // bool isActive = true, +// // int priority = 0, +// // }) async { +// // return User(name: name); +// // } diff --git a/examples/functions/lib/client.dart b/examples/functions/lib/client.dart new file mode 100644 index 00000000..3ca19e21 --- /dev/null +++ b/examples/functions/lib/client.dart @@ -0,0 +1,11 @@ +import 'package:functions/models.dart'; +import 'package:functions/rpc.client.dart'; + +void main() async { + final client = RpcClient.api(uri: Uri.parse('http://localhost:8080')); + + final r0 = await client.params(User(name: 'John'), name: 'John'); + final r1 = await client.nested.sub1(); + final r2 = await client.nested.sub2.sub3(); +} + diff --git a/examples/functions/lib/functions.dart b/examples/functions/lib/functions.dart new file mode 100644 index 00000000..93d2197b --- /dev/null +++ b/examples/functions/lib/functions.dart @@ -0,0 +1,20 @@ +class Person { + final String name; + final int age; + + Person(this.name, this.age); + + Map toJson() { + return { + 'name': name, + 'age': age, + }; + } + + factory Person.fromJson(Map json) { + return Person( + json['name'] as String, + json['age'] as int, + ); + } +} diff --git a/examples/functions/lib/models.dart b/examples/functions/lib/models.dart new file mode 100644 index 00000000..ffa7d2f5 --- /dev/null +++ b/examples/functions/lib/models.dart @@ -0,0 +1,17 @@ +class User { + final String name; + + User({ + required this.name, + }); + + Map toJson() => { + 'name': name, + }; + + factory User.fromJson(Map json) { + return User( + name: json['name'] as String, + ); + } +} diff --git a/examples/functions/lib/rpc.client.dart b/examples/functions/lib/rpc.client.dart new file mode 100644 index 00000000..fcbff35f --- /dev/null +++ b/examples/functions/lib/rpc.client.dart @@ -0,0 +1,115 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// This file was generated by shelf_rpc. +// ignore_for_file: non_constant_identifier_names, unused_local_variable, implementation_imports, no_leading_underscores_for_local_identifiers, prefer_null_aware_operators + +import 'dart:convert'; +import 'package:http/http.dart' as http; +import 'package:globe_functions/src/spec/serializer.dart'; +import 'package:globe_functions/src/client/rpc_http_client.dart'; +import 'package:functions/models.dart' as i0; +import 'package:functions/rpc.dart' as i1; + +class RpcClient extends RpcHttpClient { + RpcClient._({required super.uri, super.client}); + + static RpcApiClientEntrypoint api({required Uri uri, http.Client? client}) => + RpcApiClientEntrypoint(RpcClient._(uri: uri, client: client)); +} + +abstract base class _RpcGeneratedClient { + final RpcClient client; + const _RpcGeneratedClient(this.client); +} + +final class RpcApiNestedClient extends _RpcGeneratedClient { + const RpcApiNestedClient(super.client); + Future sub1() async { + final positional = []; + final named = {}; + + final body = jsonEncode({ + "id": "nested.sub1", + "params": {"positional": positional, "named": named}, + }); + + final result = await client.postRequest( + "nested.sub1", + namedParams: named, + positionalParams: positional, + ); + + return Serializers.instance.get().deserialize(result); + } + + RpcApiNestedSub2Client get sub2 => RpcApiNestedSub2Client(client); +} + +final class RpcApiNestedSub2Client extends _RpcGeneratedClient { + const RpcApiNestedSub2Client(super.client); + Future sub3() async { + final positional = []; + final named = {}; + + final body = jsonEncode({ + "id": "nested.sub2.sub3", + "params": {"positional": positional, "named": named}, + }); + + final result = await client.postRequest( + "nested.sub2.sub3", + namedParams: named, + positionalParams: positional, + ); + + return Serializers.instance.get().deserialize(result); + } +} + +final class RpcApiClientEntrypoint extends _RpcGeneratedClient { + const RpcApiClientEntrypoint(super.client); + + RpcApiNestedClient get nested => RpcApiNestedClient(client); + + Future params( + i0.User? user, { + int? age, + String? email, + Map? meta, + required String name, + }) async { + final positional = []; + final named = {}; + positional.add(user == null ? null : user.toJson()); + named["age"] = + age == null ? null : Serializers.instance.get().serialize(age); + named["email"] = + email == null + ? null + : Serializers.instance.get().serialize(email); + named["meta"] = + meta == null + ? null + : meta.map( + (key, value) => MapEntry( + key, + value == null + ? null + : Serializers.instance.get().serialize(value), + ), + ); + named["name"] = Serializers.instance.get().serialize(name); + + final body = jsonEncode({ + "id": "params", + "params": {"positional": positional, "named": named}, + }); + + final result = await client.postRequest( + "params", + namedParams: named, + positionalParams: positional, + ); + + return Serializers.instance.get().deserialize(result); + } +} diff --git a/examples/functions/lib/rpc.dart b/examples/functions/lib/rpc.dart new file mode 100644 index 00000000..bdefd765 --- /dev/null +++ b/examples/functions/lib/rpc.dart @@ -0,0 +1,160 @@ +import 'dart:async'; + +import 'package:functions/models.dart'; +import 'package:globe_functions/globe_functions.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_cors_headers/shelf_cors_headers.dart'; + +Future> hello(String arg, {required User user}) async { + return []; +} + +extension on String { + int getStringLength() => length; +} + +class Bar { + factory Bar() => Bar._(); + factory Bar.baz(String foo) => Bar._(); + static Bar foo() => Bar._(); + Bar._(); + + String hello(String arg, {required User user}) { + return 'hello'; + } + + Map toJson() => {}; + + factory Bar.fromJson(Map json) { + return Bar._(); + } +} + +Null neverFunction() => null; + +Future dynamicFunction() async => User(name: 'Bob'); + +// Use shelf middleware directly +final r = ShelfRpc().use(logRequests()); +final publicProcedure = r.procedure().use(corsHeaders()); + +@registerRpcEntry +final api = r.router({ + // #voidReturn: r.procedure().exec(() {}), + // #nullReturn: r.procedure().exec(method: ProcedureMethod.get, () => null), + // // #neverReturn: r.procedure().exec(neverFunction), + // #sayHello: r.procedure().exec(hello), + // #fnTearOff: r.procedure().exec(() => 123), + // // Method tear off + // #methodTearOff: r.procedure().exec(Bar().hello), + // // Factory tear off + // #factoryTearOff: r.procedure().exec(Bar.baz), + // // Static method tear off + // #staticMethodTearOff: r.procedure().exec(Bar.foo), + // // Extension method tear off + // #extensionMethodTearOff: r.procedure().exec('hello'.getStringLength), + // // Basic procedure with no middleware + // #basic: r.procedure().exec(() => 'hello world'), + // // Procedure with typed parameters + // #typed: r.procedure().exec( + // (int num, String text, [List list = const []]) async => + // '$text: $num', + // ), + // // Procedure with named parameters + // #named: r.procedure().exec(({required String name}) => 'Hello $name'), + // // Procedure with both positional and named parameters + // #mixed: r.procedure().exec(hello), + // // Async procedure returning Future + // #chained: publicProcedure.exec(hello), + // #optional: publicProcedure.exec((String foo, [String bar = 'bar']) => ['']), + // #async: r.procedure().exec(() async => 'async result'), + // #dynamic: r.procedure().exec(dynamicFunction), + // // Stream response + + // #stream: r.procedure().exec( + // () async => Stream.periodic( + // Duration(seconds: 1), + // (i) => User(name: 'tick $i'), + // ), + // ), + // #iterable: r.procedure().exec(() => Iterable.empty()), + // #async2: r.procedure().exec(() async => List.empty()), + // #datetime: r.procedure().exec( + // ({required RequestContext ctx, RequestContext? ctx2, String? foo}) => + // DateTime.now(), + // ), + // #innerRecursionCustom: r.procedure().exec( + // () => >>>[ + // [ + // [ + // [User(name: 'Alice')], + // [null], + // ], + // [ + // [User(name: 'Alice')], + // [User(name: 'Alice')], + // ], + // ], + // ], + // ), + // #innerRecursion: r.procedure().exec( + // () => >>>[ + // [ + // [ + // [null, 'foooooo'], + // ], + // ], + // ], + // ), + // #innerMap: r.procedure().exec( + // () async => >>{ + // 'foo': { + // 'bar': [User(name: 'Alice')], + // }, + // }, + // ), + // #type: r.procedure().exec(() => User(name: 'Bob')), + #params: r.procedure().exec( + ( + User? user, { + required String name, + int age = 10, + String email = 'test@test.com', + Map meta = const {}, + }) => 'hello', + ), + + #nested: r.router({ + #sub1: r.procedure().exec(() => 'nested 1'), + #sub2: r.router({#sub3: r.procedure().exec(() => 'nested 3')}), + }), + + // #ctx: r.procedure().exec((String foo, RequestContext ctx) => foo), + // #record: r.procedure().exec(() => (user: User(name: 'Bob'))), // invalid + // #function: r.procedure().exec(() => () => 'hello'), // invalid + // #asyncParam: r.procedure().exec((Future user) => 'hello'), // invalid + // // Using custom types + // #user: r.procedure().exec(() => User(name: 'Bob')), + // // Nested router example + // #nested: r.router({ + // #sub1: r.procedure().exec(() => 'nested 1'), + // #sub2: r.procedure().exec(() => 'nested 2'), + // }), + + // // Complex example combining multiple features + // #complex: publicProcedure.exec((int id, {required User user}) async { + // // req.put(DbService()); // in middleware + // // final db = req.get(); + // return User(name: 'User $id: ${user.name}'); + // }), +}); + +// @registerRpcEntry +// final foo = r.router({ +// #nest1: r.router({ +// #done: publicProcedure.exec(() => ['user1', 'user2']), +// #nest2: r.router({ +// #done: publicProcedure.exec(() => ['user1', 'user2']), +// }), +// }), +// }); diff --git a/examples/functions/lib/rpc.server.dart b/examples/functions/lib/rpc.server.dart new file mode 100644 index 00000000..c5b49ee1 --- /dev/null +++ b/examples/functions/lib/rpc.server.dart @@ -0,0 +1,118 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// This file was generated by shelf_rpc. +// ignore_for_file: non_constant_identifier_names, unused_local_variable, implementation_imports, no_leading_underscores_for_local_identifiers, prefer_null_aware_operators + +import 'dart:convert'; +import 'package:shelf/shelf.dart'; +import 'package:globe_functions/src/shelf_rpc.dart'; +import 'package:globe_functions/src/spec/serializer.dart'; +import 'package:globe_functions/src/server/request_params.dart'; +import 'package:globe_functions/src/server/request_context.dart'; +import 'package:globe_functions/src/server/sse_response.dart'; +import 'package:functions/models.dart' as i0; +import 'package:functions/rpc.dart' as i1; + +final api = Pipeline().addHandler((Request request) async { + final params = await RequestParams.fromRequest(request); + final positional = params.positional; + final named = params.named; + final _api = i1.api; + Pipeline pipeline = Pipeline(); + + for (final m in _api.middleware) { + pipeline = pipeline.addMiddleware(m); + } + + switch (params.id) { + case "params": + final procedure = _api.routes[#params] as ExecutedProcedure; + final middleware = procedure.middleware; + + for (final m in middleware) { + pipeline = pipeline.addMiddleware(m); + } + + return pipeline.addHandler((request) async { + final fn = + procedure.fn + as String Function( + i0.User? user, { + int? age, + String? email, + Map? meta, + required String name, + }); + final ctx = RequestContext(); + final p0 = + positional[0] = i0.User?.fromJson( + positional[0] as Map, + ); + final n0 = + named["age"] == null + ? null + : Serializers.instance.get().deserialize(named["age"]); + final n1 = + named["email"] == null + ? null + : Serializers.instance.get().deserialize( + named["email"], + ); + final n2 = + named["meta"] == null + ? null + : Serializers.instance.get>().deserialize( + named["meta"], + ); + final n3 = + named["name"] = Serializers.instance.get().deserialize( + named["name"], + ); + final result = fn( + p0, + age: n0 ?? 10, + email: n1 ?? 'test@test.com', + meta: n2 ?? const {}, + name: n3, + ); + final serialized = Serializers.instance.get().serialize(result); + return Response.ok(jsonEncode({"result": serialized})); + })(request); + case "nested.sub1": + final _nested = _api.routes[#nested] as Router; + final procedure = _nested.routes[#sub1] as ExecutedProcedure; + final middleware = procedure.middleware; + + for (final m in middleware) { + pipeline = pipeline.addMiddleware(m); + } + + return pipeline.addHandler((request) async { + final fn = procedure.fn as String Function(); + final ctx = RequestContext(); + + final result = fn(); + final serialized = Serializers.instance.get().serialize(result); + return Response.ok(jsonEncode({"result": serialized})); + })(request); + case "nested.sub2.sub3": + final _nested = _api.routes[#nested] as Router; + final _nested_sub2 = _nested.routes[#sub2] as Router; + final procedure = _nested_sub2.routes[#sub3] as ExecutedProcedure; + final middleware = procedure.middleware; + + for (final m in middleware) { + pipeline = pipeline.addMiddleware(m); + } + + return pipeline.addHandler((request) async { + final fn = procedure.fn as String Function(); + final ctx = RequestContext(); + + final result = fn(); + final serialized = Serializers.instance.get().serialize(result); + return Response.ok(jsonEncode({"result": serialized})); + })(request); + default: + return Response.notFound("Unknown procedure"); + } +}); diff --git a/examples/functions/lib/server.dart b/examples/functions/lib/server.dart new file mode 100644 index 00000000..df203f57 --- /dev/null +++ b/examples/functions/lib/server.dart @@ -0,0 +1,13 @@ +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as shelf_io; + +import 'rpc.server.dart' as rpc; + +void main() async { + var server = await shelf_io.serve(rpc.api, 'localhost', 8080); + + // Enable content compression + server.autoCompress = true; + + print('Serving at http://${server.address.host}:${server.port}'); +} diff --git a/examples/functions/lib/test.dart b/examples/functions/lib/test.dart new file mode 100644 index 00000000..a6dfc4c2 --- /dev/null +++ b/examples/functions/lib/test.dart @@ -0,0 +1,107 @@ +// import 'package:functions/models.dart'; + +// import 'rpc_client.g.dart'; + +// void main() async { +// final client = RpcClient('http://localhost:8080'); +// final r6 = await client.users.details.get('hello', user: User(name: 'world')); +// } +import 'dart:convert'; +import 'package:globe_functions/src/spec/serializer.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as shelf_io; +import 'package:functions/functions.dart' as i0; +import 'package:functions/api/users/details.dart' as i1; +import 'package:functions/api/users.dart' as i2; +import 'package:functions/models.dart' as i3; + +void main() async { + final handler = const Pipeline().addMiddleware(logRequests()).addHandler(_onRequest); + final server = await shelf_io.serve(handler, 'localhost', 8080); + print('Serving at http://${server.address.host}:${server.port}'); +} + +Future _onRequest(Request request) async { + if (request.method == 'POST' && request.url.path == '_rpc') { + return _onRpcRequest(request); + } + + return Response.ok('Not found...'); +} + +Future _onRpcRequest(Request request) async { + final body = jsonDecode(await request.readAsString()); + final id = body['id'] as String; + final named = body['named'] as Map; + final positional = body['positional'] as List; + + if (id == 'users.details.get') { + final param0 = Serializers.instance.deserialize(positional[0]); + final userParam = named['user'] == null ? null : i0.Person.fromJson(named['user'] as Map); + final result = await i1.get( + param0, + user: userParam + ); + final serializedResult = result.toJson(); + return Response.ok( + jsonEncode({'result': serializedResult, 'error': null}), + headers: {'content-type': 'application/json'}, + ); + } + + if (id == 'users.example1') { + final param0 = positional[0] == null ? null : Serializers.instance.deserialize(positional[0]); + final result = await i2.example1( + param0 + ); + final serializedResult = result == null ? null : result.toJson(); + return Response.ok( + jsonEncode({'result': serializedResult, 'error': null}), + headers: {'content-type': 'application/json'}, + ); + } + + if (id == 'users.example2') { + final result = await i2.example2( + + ); + final serializedResult = result.toJson(); + return Response.ok( + jsonEncode({'result': serializedResult, 'error': null}), + headers: {'content-type': 'application/json'}, + ); + } + + if (id == 'users.example3') { + final result = await i2.example3( + + ); + final serializedResult = Serializers.instance.serialize(result); + return Response.ok( + jsonEncode({'result': serializedResult, 'error': null}), + headers: {'content-type': 'application/json'}, + ); + } + + if (id == 'users.example4') { + final result = await i2.example4( + + ); + final serializedResult = Serializers.instance.serialize(result); + return Response.ok( + jsonEncode({'result': serializedResult, 'error': null}), + headers: {'content-type': 'application/json'}, + ); + } + + + // No matching function found + return Response.notFound( + jsonEncode({ + 'result': null, + 'error': 'Function not found: $id' + }), + headers: {'content-type': 'application/json'}, + ); +} + diff --git a/examples/functions/pubspec.lock b/examples/functions/pubspec.lock new file mode 100644 index 00000000..f9d3ba97 --- /dev/null +++ b/examples/functions/pubspec.lock @@ -0,0 +1,593 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "03f6da266a27a4538a69295ec142cb5717d7d4e5727b84658b63e1e1509bac9c" + url: "https://pub.dev" + source: hosted + version: "79.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.3.3" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: c9040fc56483c22a5e04a9f6a251313118b1a3c42423770623128fa484115643 + url: "https://pub.dev" + source: hosted + version: "7.2.0" + args: + dependency: transitive + description: + name: args + sha256: bf9f5caeea8d8fe6721a9c358dd8a5c1947b27f1cfaa18b39c301273594919e6 + url: "https://pub.dev" + source: hosted + version: "2.6.0" + async: + dependency: transitive + description: + name: async + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + url: "https://pub.dev" + source: hosted + version: "2.12.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0 + url: "https://pub.dev" + source: hosted + version: "2.4.2" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: "294a2edaf4814a378725bfe6358210196f5ea37af89ecd81bfa32960113d4948" + url: "https://pub.dev" + source: hosted + version: "4.0.3" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "99d3980049739a985cf9b21f30881f46db3ebc62c5b8d5e60e27440876b1ba1e" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + build_runner: + dependency: "direct main" + description: + name: build_runner + sha256: "74691599a5bc750dc96a6b4bfd48f7d9d66453eab04c7f4063134800d6a5c573" + url: "https://pub.dev" + source: hosted + version: "2.4.14" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021" + url: "https://pub.dev" + source: hosted + version: "8.0.0" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "28a712df2576b63c6c005c465989a348604960c0958d28be5303ba9baa841ac2" + url: "https://pub.dev" + source: hosted + version: "8.9.3" + change_case: + dependency: transitive + description: + name: change_case + sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003 + url: "https://pub.dev" + source: hosted + version: "2.2.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + url: "https://pub.dev" + source: hosted + version: "2.0.3" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + url: "https://pub.dev" + source: hosted + version: "4.10.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: e3493833ea012784c740e341952298f1cc77f1f01b1bbc3eb4eecf6984fb7f43 + url: "https://pub.dev" + source: hosted + version: "1.11.1" + crypto: + dependency: transitive + description: + name: crypto + sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + globe_functions: + dependency: "direct main" + description: + path: "../../packages/globe_functions" + relative: true + source: path + version: "1.0.0" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + http: + dependency: "direct main" + description: + name: http + sha256: fe7ab022b76f3034adc518fb6ea04a82387620e19977665ea18d30a1cf43442f + url: "https://pub.dev" + source: hosted + version: "1.3.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" + json: + dependency: transitive + description: + name: json + sha256: "8708a43b61848f50dbe952131fa60af35e15164ddb275d14ef8b7994ffd492ae" + url: "https://pub.dev" + source: hosted + version: "0.20.4" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + url: "https://pub.dev" + source: hosted + version: "4.9.0" + lints: + dependency: "direct dev" + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + macros: + dependency: transitive + description: + name: macros + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" + url: "https://pub.dev" + source: hosted + version: "0.1.3-main.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "92d4488434b520a62570293fbd33bb556c7d49230791c1b4bbd973baf6d2dc67" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "7b3cfbf654f3edd0c6298ecd5be782ce997ddf0e00531b9464b55245185bbbbd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_cors_headers: + dependency: "direct main" + description: + name: shelf_cors_headers + sha256: a127c80f99bbef3474293db67a7608e3a0f1f0fcdb171dad77fa9bd2cd123ae4 + url: "https://pub.dev" + source: hosted + version: "0.1.5" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + sse: + dependency: transitive + description: + name: sse + sha256: "4389a01d5bc7ef3e90fbc645f8e7c6d8711268adb1f511e14ae9c71de47ee32b" + url: "https://pub.dev" + source: hosted + version: "4.1.7" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test: + dependency: "direct dev" + description: + name: test + sha256: "8391fbe68d520daf2314121764d38e37f934c02fd7301ad18307bd93bd6b725d" + url: "https://pub.dev" + source: hosted + version: "1.25.14" + test_api: + dependency: transitive + description: + name: test_api + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + url: "https://pub.dev" + source: hosted + version: "0.7.4" + test_core: + dependency: transitive + description: + name: test_core + sha256: "84d17c3486c8dfdbe5e12a50c8ae176d15e2a771b96909a9442b40173649ccaa" + url: "https://pub.dev" + source: hosted + version: "0.6.8" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 + url: "https://pub.dev" + source: hosted + version: "15.0.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web: + dependency: transitive + description: + name: web + sha256: cd3543bd5798f6ad290ea73d210f423502e71900302dde696f8bff84bf89a1cb + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "3c12d96c0c9a4eec095246debcea7b86c0324f22df69893d538fcc6f1b8cce83" + url: "https://pub.dev" + source: hosted + version: "0.1.6" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: "9f187088ed104edd8662ca07af4b124465893caf063ba29758f97af57e61da8f" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.7.0-323.0.dev <4.0.0" diff --git a/examples/functions/pubspec.yaml b/examples/functions/pubspec.yaml new file mode 100644 index 00000000..824d605b --- /dev/null +++ b/examples/functions/pubspec.yaml @@ -0,0 +1,19 @@ +name: functions +description: A sample command-line application. +version: 1.0.0 +# repository: https://github.com/my_org/my_repo + +environment: + sdk: ^3.7.0-323.0.dev + +# Add regular dependencies here. +dependencies: + build_runner: + globe_functions: + path: ../../packages/globe_functions + http: ^1.2.2 + shelf_cors_headers: ^0.1.5 + +dev_dependencies: + lints: ^5.0.0 + test: ^1.24.0 diff --git a/examples/functions/pubspec_overrides.yaml b/examples/functions/pubspec_overrides.yaml new file mode 100644 index 00000000..fe9b24a4 --- /dev/null +++ b/examples/functions/pubspec_overrides.yaml @@ -0,0 +1,4 @@ +# melos_managed_dependency_overrides: globe_functions +dependency_overrides: + globe_functions: + path: ../../packages/globe_functions diff --git a/packages/globe_cli/pubspec.lock b/packages/globe_cli/pubspec.lock index f0389407..85a9aca4 100644 --- a/packages/globe_cli/pubspec.lock +++ b/packages/globe_cli/pubspec.lock @@ -5,23 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "45cfa8471b89fb6643fe9bf51bd7931a76b8f5ec2d65de4fb176dba8d4f22c77" + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted - version: "73.0.0" + version: "76.0.0" _macros: dependency: transitive description: dart source: sdk - version: "0.3.2" + version: "0.3.3" analyzer: dependency: "direct dev" description: name: analyzer - sha256: "4959fec185fe70cce007c57e9ab6983101dbe593d2bf8bbfb4453aaec0cf470a" + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted - version: "6.8.0" + version: "6.11.0" ansi_regex: dependency: transitive description: @@ -393,10 +393,10 @@ packages: dependency: transitive description: name: macros - sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" url: "https://pub.dev" source: hosted - version: "0.1.2-main.4" + version: "0.1.3-main.0" mason_logger: dependency: "direct main" description: diff --git a/packages/globe_functions/.gitignore b/packages/globe_functions/.gitignore new file mode 100644 index 00000000..3cceda55 --- /dev/null +++ b/packages/globe_functions/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/globe_functions/CHANGELOG.md b/packages/globe_functions/CHANGELOG.md new file mode 100644 index 00000000..effe43c8 --- /dev/null +++ b/packages/globe_functions/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/globe_functions/README.md b/packages/globe_functions/README.md new file mode 100644 index 00000000..8831761b --- /dev/null +++ b/packages/globe_functions/README.md @@ -0,0 +1,39 @@ + + +TODO: Put a short description of the package here that helps potential users +know whether this package might be useful for them. + +## Features + +TODO: List what your package can do. Maybe include images, gifs, or videos. + +## Getting started + +TODO: List prerequisites and provide or point to information on how to +start using the package. + +## Usage + +TODO: Include short and useful examples for package users. Add longer examples +to `/example` folder. + +```dart +const like = 'sample'; +``` + +## Additional information + +TODO: Tell users more about the package: where to find more information, how to +contribute to the package, how to file issues, what response they can expect +from the package authors, and more. diff --git a/packages/globe_functions/analysis_options.yaml b/packages/globe_functions/analysis_options.yaml new file mode 100644 index 00000000..31820d77 --- /dev/null +++ b/packages/globe_functions/analysis_options.yaml @@ -0,0 +1,32 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +analyzer: + enable-experiment: + - macros + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/globe_functions/build.yaml b/packages/globe_functions/build.yaml new file mode 100644 index 00000000..074bdbf7 --- /dev/null +++ b/packages/globe_functions/build.yaml @@ -0,0 +1,29 @@ +builders: + source_rpc: + import: "package:globe_functions/builders.dart" + builder_factories: ["sourceRpcBuilder"] + build_extensions: + { ".dart": [".server.dart", ".client.dart"] } + auto_apply: root_package + build_to: source + + # create_rpc_pipeline: + # import: "package:globe_functions/builders.dart" + # builder_factories: ["createRpcPipelineBuilder"] + # build_extensions: + # ".shelf_rpc_spec.json": [".server.dart"] + # auto_apply: root_package + # build_to: source + # target: ":create_rpc_pipeline" + # required_inputs: [".shelf_rpc_spec.json"] + + # create_rpc_client: + # import: "package:globe_functions/builders.dart" + # builder_factories: ["createRpcClientBuilder"] + # build_extensions: + # ".shelf_rpc_spec.json": [".client.dart"] + # auto_apply: root_package + # build_to: source + # target: ":create_rpc_client" + # required_inputs: [".shelf_rpc_spec.json"] + diff --git a/packages/globe_functions/lib/builders.dart b/packages/globe_functions/lib/builders.dart new file mode 100644 index 00000000..ba29eddc --- /dev/null +++ b/packages/globe_functions/lib/builders.dart @@ -0,0 +1,16 @@ +import 'package:build/build.dart'; +// import 'package:globe_functions/src/build/rpc_pipeline_builder.dart'; +// import 'package:globe_functions/src/build/rpc_client_builder.dart'; +import 'package:globe_functions/src/build/source_rpc_builder.dart'; + +Builder sourceRpcBuilder([BuilderOptions? options]) { + return SourceRpcBuilder(); +} + +// Builder createRpcPipelineBuilder([BuilderOptions? options]) { +// return RpcPipelineBuilder(); +// } + +// Builder createRpcClientBuilder([BuilderOptions? options]) { +// return RpcClientBuilder(); +// } diff --git a/packages/globe_functions/lib/globe_functions.dart b/packages/globe_functions/lib/globe_functions.dart new file mode 100644 index 00000000..df55b824 --- /dev/null +++ b/packages/globe_functions/lib/globe_functions.dart @@ -0,0 +1,10 @@ +/// Support for doing something awesome. +/// +/// More dartdocs go here. +library; + +export 'src/server/request_context.dart' show RequestContext; +export 'src/annotations.dart'; +export 'src/shelf_rpc.dart'; + +// TODO: Export any libraries intended for clients of this package. diff --git a/packages/globe_functions/lib/src/annotations.dart b/packages/globe_functions/lib/src/annotations.dart new file mode 100644 index 00000000..fcc98328 --- /dev/null +++ b/packages/globe_functions/lib/src/annotations.dart @@ -0,0 +1,2 @@ +import 'package:shelf/shelf.dart' show Middleware, Pipeline; + diff --git a/packages/globe_functions/lib/src/build/rpc_client_builder.dart b/packages/globe_functions/lib/src/build/rpc_client_builder.dart new file mode 100644 index 00000000..8eb1c992 --- /dev/null +++ b/packages/globe_functions/lib/src/build/rpc_client_builder.dart @@ -0,0 +1,325 @@ +// import 'dart:convert'; + +// import 'package:build/build.dart'; +// import 'package:glob/glob.dart'; +// import 'package:globe_functions/src/spec/serializer.dart'; +// import 'package:globe_functions/src/spec/sourced_procedure.dart'; +// import 'package:globe_functions/src/spec/sourced_type.dart'; + +// class RpcClientBuilder implements Builder { +// final _imports = {}; +// var _importCounter = 0; + +// String getImportPrefix(Uri? uri) { +// if (uri == null) return ''; +// if (!_imports.containsKey(uri)) { +// _imports[uri] = 'i${_importCounter++}'; +// } +// return _imports[uri]!; +// } + +// // String getTypeName(SourcedType type) { +// // if (type.uri == null) return type.type; +// // return '${getImportPrefix(type.uri!())}.${type.type}'; +// // } + +// @override +// Future build(BuildStep buildStep) async { +// // Get the original .dart file path by removing .shelf_rpc_spec.json +// final originalPath = buildStep.inputId.path.replaceAll( +// '.shelf_rpc_spec.json', +// '.dart', +// ); + +// final outputId = AssetId( +// buildStep.inputId.package, +// originalPath, +// ).changeExtension('.client.dart'); + +// await buildStep.writeAsString(outputId, '// hello client'); +// } + +// // @override +// // Future build(BuildStep buildStep) async { +// // final package = buildStep.inputId.package; + +// // final specs = +// // await buildStep.findAssets(Glob('**/functions_spec.json')).toList(); + +// // final json = await buildStep.readAsString(specs.first); + +// // final functions = +// // (jsonDecode(json) as List) +// // .map((e) => SourcedFunction.fromJson(e as Map)) +// // .toList(); + +// // print(functions); + +// // // First process all functions to collect imports +// // final pathGroups = >{}; +// // for (final func in functions) { +// // // Collect imports from return types +// // if (func.returnType.uri != null) { +// // getImportPrefix(func.returnType.uri); +// // } + +// // // Collect imports from parameters +// // for (final param in func.parameters) { +// // if (param.type.uri != null) { +// // getImportPrefix(param.type.uri); +// // } +// // } + +// // // Group functions as before +// // final pathParts = func.uri.pathSegments.sublist( +// // func.uri.pathSegments.indexOf('api') + 1, +// // ); +// // pathParts[pathParts.length - 1] = pathParts.last.replaceAll('.dart', ''); +// // final path = pathParts.join('/'); +// // pathGroups[path] = [...(pathGroups[path] ?? []), func]; +// // } + +// // final content = StringBuffer(''' +// // // ignore_for_file: implementation_imports, unnecessary_cast +// // // GENERATED CODE - DO NOT MODIFY BY HAND +// // // This code was generated by globe_functions + +// // '''); + +// // // Add all required imports at the top +// // content.writeln("import 'package:http/http.dart' as http;"); +// // content.writeln("import 'dart:convert';"); +// // content.writeln( +// // "import 'package:globe_functions/src/build/serializer.dart' show Serializer, SerializerType;", +// // ); + +// // // Add all collected imports for types +// // for (final entry in _imports.entries) { +// // content.writeln("import '${entry.key}' as ${entry.value};"); +// // } +// // content.writeln(); + +// // // Write the base class +// // content.writeln(''' +// // abstract class RpcBaseClient { +// // final String baseUrl; +// // final http.Client client; +// // final String path; + +// // RpcBaseClient({ +// // required this.baseUrl, +// // required this.client, +// // required this.path, +// // }); + +// // String get fullPath => '\$baseUrl/\$path'; +// // } +// // '''); + +// // // Helper to check if a path has subpaths +// // bool hasSubpath(String currentPath, String segment) { +// // return pathGroups.keys.any((p) { +// // if (!p.startsWith('$currentPath/')) return false; + +// // final remaining = p.substring(currentPath.length + 1); +// // if (remaining.isEmpty) return false; + +// // return remaining.split('/')[0] == segment; +// // }); +// // } + +// // // Generate segment classes from root to leaf +// // final processedPaths = {}; +// // for (final path in pathGroups.keys) { +// // var currentPath = ''; +// // final segments = path.split('/'); + +// // for (var i = 0; i < segments.length; i++) { +// // currentPath = i == 0 ? segments[i] : '$currentPath/${segments[i]}'; + +// // if (processedPaths.contains(currentPath)) continue; +// // processedPaths.add(currentPath); + +// // final className = _generateClassName(segments.sublist(0, i + 1)); + +// // // Check if this segment should be callable +// // final parentPath = segments.sublist(0, i).join('/'); +// // final isCallable = +// // pathGroups[parentPath]?.any( +// // (func) => func.functionName == segments[i], +// // ) ?? +// // false; + +// // content.writeln(''' +// // class $className extends RpcBaseClient { +// // $className({ +// // required super.baseUrl, +// // required super.client, +// // }) : super(path: '$currentPath'); +// // '''); + +// // // Add call() method if this segment is callable +// // if (isCallable) { +// // content.writeln(''' +// // Future call() async { +// // final response = await client.post( +// // Uri.parse(fullPath), +// // body: jsonEncode({ +// // 'positional': [], +// // 'named': {}, +// // }), +// // ); +// // return response.body as String; +// // } +// // '''); +// // } + +// // // Add methods for this path level +// // if (pathGroups.containsKey(currentPath)) { +// // for (final func in pathGroups[currentPath]!) { +// // final baseReturnType = getTypeName(func.returnType); +// // final returnType = 'Future<$baseReturnType>'; + +// // // Generate parameter list +// // final parameters = []; +// // final positionalArgs = []; +// // final namedArgs = []; +// // final namedParams = []; +// // final optionalPositionals = []; +// // final paramTypes = {}; + +// // for (final param in func.parameters) { +// // final paramType = getTypeName(param.type); +// // if (param.isNamed) { +// // final required = param.isRequired ? 'required ' : ''; +// // final defaultValue = +// // param.defaultValue != null +// // ? ' = ${param.defaultValue}' +// // : ''; +// // namedParams.add( +// // '$required$paramType ${param.name}$defaultValue', +// // ); +// // namedArgs.add("'${param.name}': ${param.name}"); +// // paramTypes[param.name] = param.type; // Store with unquoted name +// // } else if (param.isOptional) { +// // final defaultValue = +// // param.defaultValue != null +// // ? ' = ${param.defaultValue}' +// // : ''; +// // optionalPositionals.add( +// // '$paramType ${param.name}$defaultValue', +// // ); +// // positionalArgs.add(param.name); +// // paramTypes[param.name] = +// // param.type; // Store for optional params +// // } else { +// // parameters.add('$paramType ${param.name}'); +// // positionalArgs.add(param.name); +// // paramTypes[param.name] = +// // param.type; // Store for required params +// // } +// // } + +// // // Add optional positional parameters in a single set of brackets if there are any +// // if (optionalPositionals.isNotEmpty) { +// // parameters.add('[${optionalPositionals.join(', ')}]'); +// // } + +// // // Add named parameters inside a single set of braces if there are any +// // if (namedParams.isNotEmpty) { +// // parameters.add('{${namedParams.join(', ')}}'); +// // } + +// // if (!hasSubpath(currentPath, func.functionName)) { +// // content.writeln(''' +// // $returnType ${func.functionName}(${parameters.join(', ')}) async { +// // final response = await client.post( +// // Uri.parse('\$fullPath/${func.functionName}'), +// // body: jsonEncode({ +// // 'positional': [${positionalArgs.map((arg) => 'Serializer.serialize(${_getSerializerType(paramTypes[arg]!)}, $arg)').join(', ')}], +// // 'named': {${namedArgs.map((arg) { +// // final name = arg.split(':')[0].substring(1).replaceAll("'", ''); // Remove quotes +// // final value = arg.split(':')[1].trim(); +// // return "'$name': Serializer.serialize(${_getSerializerType(paramTypes[name]!)}, $value)"; +// // }).join(', ')}}, +// // }), +// // ); +// // return ${_generateResponseHandler(func.returnType)}; +// // } +// // '''); +// // } +// // } +// // } + +// // // Add getters for next level +// // if (i < segments.length - 1) { +// // final nextSegment = segments[i + 1]; +// // final nextClassName = _generateClassName(segments.sublist(0, i + 2)); +// // content.writeln(''' +// // $nextClassName get $nextSegment => $nextClassName( +// // baseUrl: baseUrl, +// // client: client, +// // ); +// // '''); +// // } + +// // content.writeln('}'); +// // content.writeln(); +// // } +// // } + +// // // Generate the main RpcClient class +// // content.writeln(''' +// // class RpcClient { +// // final String baseUrl; +// // final http.Client client; + +// // RpcClient(this.baseUrl, [http.Client? _client]) : client = _client ?? http.Client(); +// // '''); + +// // // Add top-level getters +// // final topLevelSegments = +// // pathGroups.keys.map((p) => p.split('/').first).toSet(); + +// // for (final segment in topLevelSegments) { +// // final className = _generateClassName([segment]); +// // content.writeln(''' +// // $className get $segment => $className( +// // baseUrl: baseUrl, +// // client: client, +// // );'''); +// // } + +// // content.writeln('}'); + +// // // Write the generated file +// // await buildStep.writeAsString( +// // AssetId(package, 'lib/rpc_client.g.dart'), +// // content.toString(), +// // ); +// // } + +// // String _generateClassName(List segments) { +// // return '${segments.map((s) => s[0].toUpperCase() + s.substring(1)).join()}Segment'; +// // } + +// // String _generateResponseHandler(SourcedType returnType) { +// // final resultExtraction = 'jsonDecode(response.body)["result"]'; + +// // if (returnType.serializerType == SerializerType.clazz) { +// // final prefix = getImportPrefix(returnType.uri!()); +// // return '$prefix.${returnType.type}.fromJson($resultExtraction)'; +// // } + +// // return 'Serializer.fromJson($resultExtraction).deserialize() as ${returnType.type}'; +// // } + +// // String _getSerializerType(SourcedType type) { +// // return 'SerializerType.${type.serializerType.name}'; +// // } + +// @override +// Map> get buildExtensions => { +// ".shelf_rpc_spec.json": [".client.dart"], +// }; +// } diff --git a/packages/globe_functions/lib/src/build/rpc_pipeline_builder.dart b/packages/globe_functions/lib/src/build/rpc_pipeline_builder.dart new file mode 100644 index 00000000..df967104 --- /dev/null +++ b/packages/globe_functions/lib/src/build/rpc_pipeline_builder.dart @@ -0,0 +1,284 @@ +// import 'dart:convert'; + +// import 'package:build/build.dart'; +// import 'package:dart_style/dart_style.dart'; +// import 'package:globe_functions/src/spec/sourced_entry.dart'; +// import 'package:globe_functions/src/spec/sourced_parameter.dart'; +// import 'package:globe_functions/src/spec/sourced_procedure.dart'; +// import 'package:globe_functions/src/spec/sourced_type.dart'; + +// class RpcPipelineBuilder implements Builder { +// final formatter = DartFormatter( +// languageVersion: DartFormatter.latestLanguageVersion, +// ); + +// @override +// Future build(BuildStep buildStep) async { +// final content = await buildStep.readAsString(buildStep.inputId); +// final spec = json.decode(content) as Map; + +// final b = StringBuffer(); + +// // Add imports +// b.writeln('// GENERATED CODE - DO NOT MODIFY BY HAND'); +// b.writeln( +// '// ignore_for_file: non_constant_identifier_names, unused_local_variable, implementation_imports', +// ); +// b.writeln(); +// b.writeln("import 'dart:convert';"); +// b.writeln("import 'package:shelf/shelf.dart';"); +// b.writeln("import 'package:globe_functions/src/shelf_rpc.dart';"); +// b.writeln( +// "import 'package:globe_functions/src/server/request_context.dart';", +// ); +// b.writeln("import 'package:globe_functions/src/spec/serializer.dart';"); + +// // Add imports from spec +// final imports = (spec['imports'] as List).asMap(); +// for (final entry in imports.entries) { +// b.writeln("import '${entry.value}' as i${entry.key};"); +// } + +// b.writeln(); + +// // Get entrypoints +// final entrypoints = (spec['entrypoints'] as List).map( +// (e) => SourcedEntry.fromJson(e as Map), +// ); + +// // Create handlers for each exported variable +// for (final entrypoint in entrypoints) { +// b.writeln('final ${entrypoint.name} = Pipeline()'); +// b.writeln(' .addHandler((Request request) async {'); +// b.writeln(' if (request.method != "POST") {'); +// b.writeln(' return Response(405, body: "Method not allowed");'); +// b.writeln(' }'); +// b.writeln(' final payload = await request.readAsString();'); +// b.writeln( +// ' final json = jsonDecode(payload) as Map;', +// ); +// b.writeln(' final id = json["id"] as String;'); +// b.writeln(' final params = json["params"] as Map;'); + +// // Import the entrypoint from the users library. +// b.writeln( +// ' final _${entrypoint.name} = i${entrypoint.importId}.${entrypoint.name};', +// ); + +// // Create a pipeline and add middleware for the entrypoint. +// b.writeln(' Pipeline pipeline = Pipeline();'); +// b.writeln(); +// b.writeln(' for (final m in _${entrypoint.name}.middleware) {'); +// b.writeln(' pipeline = pipeline.addMiddleware(m);'); +// b.writeln(' }'); + +// // Add switch statement for procedures. +// b.writeln(); +// b.writeln(' switch (id) {'); + +// for (final procedure in entrypoint.procedures) { +// b.writeln(' case "${procedure.id}":'); +// b.writeln(_buildHandlerCall(entrypoint, procedure)); +// } + +// b.writeln(' default:'); +// b.writeln(' return Response.notFound("Unknown procedure");'); +// b.writeln(' }'); +// b.writeln(' });'); +// b.writeln(); +// } + +// // Write the output file +// final originalPath = buildStep.inputId.path.replaceAll( +// '.shelf_rpc_spec.json', +// '.dart', +// ); +// final outputId = AssetId( +// buildStep.inputId.package, +// originalPath, +// ).changeExtension('.server.dart'); + +// await buildStep.writeAsString(outputId, formatter.format(b.toString())); +// } + +// String _buildHandlerCall( +// SourcedEntry entrypoint, +// SourcedProcedure procedure, +// ) { +// final parts = procedure.id.split('.'); + +// final b = StringBuffer(); + +// if (parts.length == 1) { +// b.writeln( +// 'final procedure = _${entrypoint.name}.routes[#${parts[0]}] as ExecutedProcedure;', +// ); +// b.writeln('final middleware = procedure.middleware;'); +// } else { +// var i = 0; +// for (final part in parts) { +// final isLast = i == parts.length - 1; +// if (isLast) { +// final prevVar = +// i == 0 ? '_${entrypoint.name}' : '_${parts.take(i).join('_')}'; +// b.writeln( +// 'final procedure = $prevVar.routes[#$part] as ExecutedProcedure;', +// ); +// b.writeln('final middleware = procedure.middleware;'); +// } else { +// final prevVar = +// i == 0 ? '_${entrypoint.name}' : '_${parts.take(i).join('_')}'; +// final nextVar = '_${parts.take(i + 1).join('_')}'; +// b.writeln('final $nextVar = $prevVar.routes[#$part] as Router;'); +// } +// i++; +// } +// } + +// // Add middleware for the route. +// b.writeln(); +// b.writeln('for (final m in middleware) {'); +// b.writeln(' pipeline = pipeline.addMiddleware(m);'); +// b.writeln('}'); + +// // Add handler to the pipeline. +// b.writeln(); +// b.writeln('return pipeline.addHandler((request) async {'); +// b.writeln(_buildFunctionHandler(procedure)); +// b.writeln('})(request);'); + +// return b.toString(); +// } + +// String _buildFunctionHandler(SourcedProcedure procedure) { +// final b = StringBuffer(); + +// b.writeln('final fn = procedure.fn as ${procedure.typeDefinition};'); + +// if (procedure.parameters.any((p) => p.isRequestContext)) { +// b.writeln('final ctx = RequestContext();'); +// } + +// final positional = procedure.parameters.where((p) => p.isPositional); +// final named = procedure.parameters.where((p) => p.isNamed); + +// if (positional.isNotEmpty) { +// b.writeln('final positional = params["positional"] as List;'); +// } + +// if (named.isNotEmpty) { +// b.writeln('final named = params["named"] as Map;'); +// } + +// // Build each positional parameter in order. +// var p = 0; +// for (final param in positional) { +// b.write('final p$p = '); +// if (param.isRequestContext) { +// b.writeln('ctx;'); +// } else if (param.isOptional || param.type.isNullable) { +// if (param.type.importId != null) { +// b.writeln( +// 'positional[$p] == null ? null : i${param.type.importId}.${param.type.type}.fromJson(positional[$p] as Map);', +// ); +// } else { +// b.writeln( +// 'positional[$p] == null ? null : Serializers.instance.get<${param.type.type}>().deserialize(positional[$p]);', +// ); +// } +// } else if (param.type.importId != null) { +// b.writeln( +// 'i${param.type.importId}.${param.type.type}.fromJson(positional[$p] as Map);', +// ); +// } else { +// b.writeln( +// 'Serializers.instance.get<${param.type.typeDefinition}>().deserialize(positional[$p]);', +// ); +// } +// p++; +// } + +// var n = 0; +// for (final param in named) { +// b.write('final n$n = '); +// if (param.isRequestContext) { +// b.writeln('ctx;'); +// } else if (param.isOptional || param.type.isNullable) { +// if (param.type.importId != null) { +// b.writeln( +// 'named["${param.name}"] == null ? null : i${param.type.importId}.${param.type.type}.fromJson(named["${param.name}"]);', +// ); +// } else { +// b.writeln( +// 'named["${param.name}"] == null ? null : Serializers.instance.get<${param.type.typeDefinition}>().deserialize(named["${param.name}"]);', +// ); +// } +// } else if (param.type.importId != null) { +// b.writeln( +// 'i${param.type.importId}.${param.type.type}.fromJson(named["${param.name}"]);', +// ); +// } else { +// b.writeln( +// 'Serializers.instance.get<${param.type.typeDefinition}>().deserialize(named["${param.name}"]);', +// ); +// } +// n++; +// } + +// // Call the procedure handler. +// b.writeln(); +// b.write('final result = '); + +// switch (procedure.type.kind) { +// case SourcedTypeKind.future: +// b.writeln('await fn('); +// break; +// case SourcedTypeKind.stream: +// throw UnimplementedError( +// 'Iterable type is not supported for RPC pipelines', +// ); +// case SourcedTypeKind.iterable: +// case SourcedTypeKind.list: +// case SourcedTypeKind.set: +// case SourcedTypeKind.map: +// default: +// b.writeln('fn('); +// break; +// } + +// for (var i = 0; i < positional.length; i++) { +// b.writeln('p$i,'); +// } +// for (var i = 0; i < named.length; i++) { +// final param = named.elementAt(i); +// b.write('${param.name}: n$i'); +// if (param.defaultValue != null) { +// b.write(' ?? ${param.defaultValue}'); +// } +// b.writeln(','); +// } +// b.writeln(');'); + +// b.writeln(); +// b.write('final serialized = '); +// if (procedure.type.isNullable) { +// b.writeln('result == null ? null : '); +// } +// if (procedure.type.importId != null) { +// b.writeln('result.toJson();'); +// } else { +// b.writeln( +// 'Serializers.instance.get<${procedure.type.typeDefinition}>().serialize(result);', +// ); +// } + +// b.writeln('return Response.ok(jsonEncode({ "result": serialized }));'); + +// return b.toString(); +// } + +// @override +// Map> get buildExtensions => { +// ".shelf_rpc_spec.json": [".server.dart"], +// }; +// } diff --git a/packages/globe_functions/lib/src/build/source_rpc_builder.dart b/packages/globe_functions/lib/src/build/source_rpc_builder.dart new file mode 100644 index 00000000..808392b0 --- /dev/null +++ b/packages/globe_functions/lib/src/build/source_rpc_builder.dart @@ -0,0 +1,434 @@ +import 'dart:convert'; + +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/nullability_suffix.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:build/build.dart'; +import 'package:globe_functions/src/annotations.dart'; +import 'package:globe_functions/src/build/writers/client_writer.dart'; +import 'package:globe_functions/src/build/writers/server_writer.dart'; +import 'package:globe_functions/src/shelf_rpc.dart'; +import 'package:globe_functions/src/spec/rpc_procedure.dart'; +import 'package:globe_functions/src/spec/rpc_procedure_parameter.dart'; +import 'package:globe_functions/src/spec/serializer.dart'; +import 'package:globe_functions/src/spec/sourced_entry.dart'; +import 'package:globe_functions/src/spec/sourced_procedure.dart'; +import 'package:globe_functions/src/spec/sourced_parameter.dart'; +import 'package:globe_functions/src/spec/sourced_imports.dart'; +// import 'package:globe_functions/src/spec/sourced_serializers.dart'; +import 'package:globe_functions/src/server/request_context.dart'; +import 'package:globe_functions/src/spec/sourced_type.dart'; +import 'package:globe_functions/src/spec/types.dart'; +import 'package:source_gen/source_gen.dart'; + +const _routerType = TypeChecker.fromRuntime(Router); +const _executedProcedureType = TypeChecker.fromRuntime(ExecutedProcedure); + +// Any reserved names which are exposed in the client to prevent conflicts. +const _reservedNames = ['postRequest', 'getRequest', 'sseRequest']; + +class SourceRpcBuilder extends Builder { + final imports = SourcedImports(); + // final serializers = SourcedSerializers(); + + @override + Map> get buildExtensions => { + r'.dart': ['.server.dart', '.client.dart'], + }; + + @override + Future build(BuildStep buildStep) async { + final library = await buildStep.resolver.libraryFor(buildStep.inputId); + final reader = LibraryReader(library); + + final registered = reader.annotatedWith( + TypeChecker.fromRuntime(ShelfRpcRegistry), + ); + + if (registered.isEmpty) { + return; + } + + final imports = SourcedImports(); + final entrypoints = []; + + for (final annotated in registered) { + if (annotated.element is! TopLevelVariableElement) { + throw InvalidGenerationSourceError( + 'The @ShelfRpcRegistry annotation can only be applied to top-level functions.', + element: annotated.element, + ); + } + + final element = annotated.element as TopLevelVariableElement; + + if (element.isPrivate) { + throw InvalidGenerationSourceError( + '@ShelfRpcRegistry annotation must be used on a public top-level variable.', + element: element, + ); + } + + if (_reservedNames.contains(element.name)) { + throw InvalidGenerationSourceError( + '@ShelfRpcRegistry cannot use the ${element.name} variable, it is reserved for internal use.', + element: element, + ); + } + + if (!_routerType.isExactlyType(element.type)) { + throw InvalidGenerationSourceError( + '@ShelfRpcRegistry annotation must be used on a Router instance', + element: element, + ); + } + + final declaration = await buildStep.resolver.astNodeFor( + element, + resolve: true, + ); + + if (declaration == null || declaration is! VariableDeclaration) { + throw InvalidGenerationSourceError( + 'Unable to find a Router instance (expected a variable declaration)', + element: element, + ); + } + + final initializer = declaration.initializer; + + if (initializer is! MethodInvocation) { + throw InvalidGenerationSourceError( + 'Unable to find a Router instance (expected a method invocation)', + element: element, + ); + } + + // Routes can only be initialized with a Map as the single argument. + final routes = + initializer.argumentList.arguments.single as SetOrMapLiteral; + + final procedures = []; + + void processRoutes( + List elements, [ + List? prefix = const [], + ]) { + for (final (element as MapLiteralEntry) in elements) { + final key = (element.key as SymbolLiteral).name; + final value = element.value as MethodInvocation; + + // Skip private routes and duplicate routes. + if (key.startsWith('_')) { + continue; + } + + final idChunks = prefix == null ? [key] : [...prefix, key]; + final id = idChunks.join('.'); + + if (procedures.any((p) => p.id == id)) { + continue; + } + + switch (value.staticType!) { + // If the procedure is a nested router, process the nested routes. + case final type when _routerType.isExactlyType(type): + final nestedRoutes = + value.argumentList.arguments.single as SetOrMapLiteral; + return processRoutes(nestedRoutes.elements, idChunks); + + // If the procedure is executed, process the function. + case final type when _executedProcedureType.isExactlyType(type): + { + final expression = value.argumentList.arguments.single; + + // Get function type and details + DartType? function; + List parameters = []; + + if (expression is SimpleIdentifier) { + final element = expression.staticElement; + if (element is FunctionElement) { + function = element.type.returnType; + parameters = element.parameters; + } + } else if (expression is ConstructorReference) { + final element = expression.constructorName.staticElement; + if (element is ConstructorElement) { + function = element.returnType; + parameters = element.parameters; + } + } else if (expression is PropertyAccess) { + final element = expression.propertyName.staticElement; + if (element is MethodElement) { + function = element.returnType; + parameters = element.parameters; + } + } else if (expression is FunctionExpression) { + final type = expression.staticType; + if (type is FunctionType) { + function = type.returnType; + parameters = type.parameters; + } + } else if (expression is PrefixedIdentifier) { + // Handle static method references like: + // #staticMethodTearOff: r.procedure().exec(Bar.foo), + final element = expression.staticElement; + if (element is MethodElement) { + function = element.returnType; + parameters = element.parameters; + } + } + + // Shouldn't get to here... + if (function == null) { + throw InvalidGenerationSourceError( + 'Unable to determine return procedure: ${expression.runtimeType}', + ); + } + + final interface = SupportedType.fromDartType( + imports: imports, + type: function, + library: library, + )..assertValid(); + + final paramInterfaces = + parameters.map((parameter) { + final interface = SupportedType.fromDartType( + imports: imports, + type: parameter.type, + library: library, + ); + + if (interface is AsyncType) { + throw InvalidGenerationSourceError( + 'Parameters cannot be async.', + element: parameter, + ); + } + + interface.assertValid(); + + return RpcProcedureParameter(interface, parameter); + }).toList(); + + procedures.add( + RpcProcedure( + id: id, + interface: interface, + parameters: paramInterfaces, + ), + ); + + // // Get the sourced return type + // final returnType = SourcedType.fromDartType( + // imports: imports, + // library: library, + // type: function, + // ); + + // // If the user has returned a Future, FutureOr, or Stream, we need + // // to get the type of the value they are returning, e.g. + // // Future -> String. + // final serializedType = switch (returnType.kind) { + // SourcedTypeKind.future => function, + // SourcedTypeKind.futureOr => returnType.typeArguments.first, + // SourcedTypeKind.stream => returnType.typeArguments.first, + // _ => returnType, + // }; + + // // Get the sourced parameters types + // final parameterTypes = [ + // for (final parameter in parameters) + // SourcedType.fromDartType( + // imports: imports, + // library: library, + // type: parameter.type, + // ), + // ]; + + // // Don't allow async parameters. + // for (var i = 0; i < parameterTypes.length; i++) { + // switch (parameterTypes[i].kind) { + // case SourcedTypeKind.future: + // case SourcedTypeKind.futureOr: + // case SourcedTypeKind.stream: + // throw InvalidGenerationSourceError( + // 'Parameters cannot be async.', + // element: parameters[i], + // ); + // default: + // break; + // } + // } + + // Get the sourced parameters + // final sourcedParameters = + // parameters.map((parameter) { + // // Get the resolved parameter type. + // final parameterType = switch (parameter.type) { + // // If the parameter is an async type, throw an error. + // InterfaceType type + // when type.isDartAsyncFuture || + // type.isDartAsyncFutureOr || + // type.isDartAsyncStream => + // throw InvalidGenerationSourceError( + // 'Parameters cannot be async.', + // element: parameter, + // ), + // // If the parameter is a symbol, function, or record, throw an + // // error as we can't serialize these. + // InterfaceType type + // when type.isDartCoreSymbol || + // type.isDartCoreFunction || + // type.isDartCoreRecord => + // throw InvalidGenerationSourceError( + // 'Unsupported parameter type: ${parameter.type}', + // element: parameter, + // ), + // // If the parameter is a function or record, throw an + // // error as we can't serialize these. + // FunctionType _ || RecordType _ => + // throw InvalidGenerationSourceError( + // 'Unsupported parameter type: ${parameter.type}', + // element: parameter, + // ), + // _ => parameter.type, + // }; + + // // Check if the parameter is the RequestContext type. + // final isRequestContext = _requestContextType + // .isExactlyType(parameter.type); + + // return SourcedParameter( + // name: parameter.name, + // type: SourcedType.fromDartType( + // imports: imports, + // // serializers: serializers, + // library: library, + // type: parameterType, + // ), + // defaultValue: parameter.defaultValueCode, + // isNamed: parameter.isNamed, + // isPositional: parameter.isPositional, + // isOptional: + // parameter.isOptionalNamed || + // parameter.isOptionalPositional, + // isRequired: + // parameter.isRequiredNamed || + // parameter.isRequiredPositional, + // isRequestContext: isRequestContext, + // ); + // }).toList(); + + // procedures.add( + // SourcedProcedure( + // id: id.join('.'), + // type: SourcedType.fromDartType( + // imports: imports, + // // serializers: serializers, + // library: library, + // type: returnType, + // ), + // parameters: sourcedParameters, + // ), + // ); + } + case _: + throw Exception('Invalid procedure type.'); + } + } + } + + // Iterate over the root routes in the router. + processRoutes(routes.elements); + + entrypoints.add( + SourcedEntry( + importId: imports.register(element.source!.uri), + name: element.name, + procedures: procedures, + ), + ); + } + + final server = ServerWriter(imports: imports, entrypoints: entrypoints); + final client = ClientWriter(imports: imports, entrypoints: entrypoints); + final asset = AssetId(buildStep.inputId.package, buildStep.inputId.path); + + await Future.wait([ + buildStep.writeAsString( + asset.changeExtension('.server.dart'), + server.write(), + ), + buildStep.writeAsString( + asset.changeExtension('.client.dart'), + client.write(), + ), + ]); + + // if (entrypoints.isEmpty) { + // return; + // } + + // final spec = jsonEncode({ + // 'entrypoints': entrypoints.map((e) => e.toJson()).toList(), + // 'imports': imports.toJson(), + // // 'serializers': serializers.toJson(), + // }); + + // await buildStep.writeAsString( + // buildStep.inputId.changeExtension('.shelf_rpc_spec.json'), + // spec, + // ); + } +} + +extension on SymbolLiteral { + /// Returns the name of the symbol without the leading `#` character. + String get name { + return toString().substring(1); + } +} + +// class TypeInfo { +// final DartType type; +// final String typeName; +// final List typeArguments; + +// TypeInfo({ +// required this.type, +// required this.typeName, +// this.typeArguments = const [], +// }); + +// factory TypeInfo.fromDartType(DartType type) { +// return switch (type) { +// InterfaceType interface +// when interface.isDartCoreFunction || +// interface.isDartCoreSymbol || +// interface.isDartCoreRecord => +// throw InvalidGenerationSourceError( +// 'Unsupported type: ${interface.getDisplayString(withNullability: false)}', +// ), +// InterfaceType interface => TypeInfo( +// type: type, +// typeName: interface.element.name, +// typeArguments: +// interface.typeArguments +// .map((t) => TypeInfo.fromDartType(t)) +// .toList(), +// ), +// RecordType _ => +// throw InvalidGenerationSourceError('Record types are not supported'), +// FunctionType _ => +// throw InvalidGenerationSourceError('Function types are not supported'), +// _ => TypeInfo( +// type: type, +// typeName: type.getDisplayString(withNullability: false), +// ), +// }; +// } +// } diff --git a/packages/globe_functions/lib/src/build/writers/base_writer.dart b/packages/globe_functions/lib/src/build/writers/base_writer.dart new file mode 100644 index 00000000..c6369bc1 --- /dev/null +++ b/packages/globe_functions/lib/src/build/writers/base_writer.dart @@ -0,0 +1,35 @@ +import 'package:dart_style/dart_style.dart'; +import 'package:globe_functions/src/spec/rpc_procedure.dart'; +import 'package:globe_functions/src/spec/sourced_entry.dart'; +import 'package:globe_functions/src/spec/sourced_imports.dart'; +import 'package:globe_functions/src/spec/types.dart'; + +const header = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// This file was generated by shelf_rpc. +// ignore_for_file: non_constant_identifier_names, unused_local_variable, implementation_imports, no_leading_underscores_for_local_identifiers, prefer_null_aware_operators + +'''; + +abstract class BaseWriter { + BaseWriter({required this.imports, required this.entrypoints}); + final SourcedImports imports; + final List entrypoints; + + final formatter = DartFormatter( + languageVersion: DartFormatter.latestLanguageVersion, + ); + + final StringBuffer b = StringBuffer(header.trimLeft()); + + String write(); + + String writeImports() { + final b = StringBuffer(); + for (var i = 0; i < imports.imports.length; i++) { + final uri = imports.imports.elementAt(i); + b.writeln("import '$uri' as i$i;"); + } + return b.toString(); + } +} diff --git a/packages/globe_functions/lib/src/build/writers/client_writer.dart b/packages/globe_functions/lib/src/build/writers/client_writer.dart new file mode 100644 index 00000000..3f8bc0bd --- /dev/null +++ b/packages/globe_functions/lib/src/build/writers/client_writer.dart @@ -0,0 +1,357 @@ +import 'package:globe_functions/src/build/writers/base_writer.dart'; +import 'package:change_case/change_case.dart'; +import 'package:globe_functions/src/spec/rpc_procedure.dart'; +import 'package:globe_functions/src/spec/sourced_entry.dart'; +import 'package:globe_functions/src/spec/types.dart'; + +class ClientWriter extends BaseWriter { + ClientWriter({required super.imports, required super.entrypoints}); + + @override + String write() { + b.writeln("import 'dart:convert';"); + b.writeln("import 'package:http/http.dart' as http;"); + b.writeln("import 'package:globe_functions/src/spec/serializer.dart';"); + b.writeln( + "import 'package:globe_functions/src/client/rpc_http_client.dart';", + ); + + // Add imports with unique import indexes + b.write(writeImports()); + b.writeln(); + + // Write the RpcClient class with named constructors for each entrypoint + b.writeln("class RpcClient extends RpcHttpClient {"); + b.writeln(); + + // Private constructor + b.writeln(" RpcClient._({required super.uri, super.client});"); + + // Static factory methods for each entrypoint + for (final entrypoint in entrypoints) { + final entrypointClassName = _generateClientClassName( + entrypoint, + '', + isEntrypoint: true, + ); + b.writeln(); + b.writeln(''' + static $entrypointClassName ${entrypoint.name}({ + required Uri uri, + http.Client? client, + }) => $entrypointClassName(RpcClient._(uri: uri, client: client)); +'''); + } + + b.writeln("}"); + b.writeln(); + + // First write the base class for all generated clients + b.writeln(''' +abstract base class _RpcGeneratedClient { + final RpcClient client; + const _RpcGeneratedClient(this.client); +} +'''); + + for (final entrypoint in entrypoints) { + final routes = _groupProceduresByPath(entrypoint.procedures); + final allPaths = _getAllPaths(routes.keys); + + // Generate internal client classes for all paths + for (final path in allPaths.where((k) => k.isNotEmpty)) { + b.writeln(); + _writeNestedClientClass(entrypoint, path, routes[path] ?? [], allPaths); + } + + // Generate the public entrypoint class + final entrypointClassName = _generateClientClassName( + entrypoint, + '', + isEntrypoint: true, + ); + b.writeln( + "final class $entrypointClassName extends _RpcGeneratedClient {", + ); + b.writeln(); + b.writeln(" const $entrypointClassName(super.client);"); + + // Generate getters for nested routes + _writeNestedRouteGetters(entrypoint, routes); + b.writeln(); + + // Write root level methods + if (routes[''] != null) { + for (final procedure in routes['']!) { + _writeProcedureMethod(procedure); + b.writeln(); + } + } + + b.writeln("}"); + } + + return formatter.format(b.toString()); + } + + Map> _groupProceduresByPath( + List procedures, + ) { + final routes = >{}; + for (final procedure in procedures) { + final segments = procedure.id.split('.'); + final path = + segments.length == 1 + ? '' + : segments.take(segments.length - 1).join('.'); + routes[path] ??= []; + routes[path]!.add(procedure); + } + return routes; + } + + Set _getAllPaths(Iterable routes) { + final allPaths = {}; + + for (final route in routes) { + if (route.isEmpty) continue; + + final segments = route.split('.'); + var currentPath = ''; + + // Add all intermediate paths + for (var i = 0; i < segments.length; i++) { + currentPath = i == 0 ? segments[0] : '$currentPath.${segments[i]}'; + allPaths.add(currentPath); + } + } + + return allPaths; + } + + String _generateClientClassName( + SourcedEntry entrypoint, + String path, { + bool isEntrypoint = false, + }) { + final parts = [ + 'Rpc', + entrypoint.className, + ...path.split('.').map((s) => s.toPascalCase()), + if (isEntrypoint) 'ClientEntrypoint' else 'Client', + ]; + + return parts.join(); + } + + void _writeNestedClientClass( + SourcedEntry entrypoint, + String path, + List procedures, + Set allPaths, + ) { + final className = _generateClientClassName(entrypoint, path); + b.writeln('final class $className extends _RpcGeneratedClient {'); + b.writeln(' const $className(super.client);'); + + // Write methods for this path level + for (final procedure in procedures) { + _writeProcedureMethod(procedure); + } + + // Add getters for next level paths + final currentSegments = path.split('.'); + final nextLevelPaths = + allPaths + .where((p) => p.startsWith('$path.')) + .where((p) => p.split('.').length == currentSegments.length + 1) + .map((p) => p.split('.').last) + .toSet(); + + for (final nextPath in nextLevelPaths) { + final nextClassName = _generateClientClassName( + entrypoint, + '$path.$nextPath', + ); + b.writeln( + ' $nextClassName get ${nextPath.toCamelCase()} => $nextClassName(client);', + ); + } + + b.writeln('}'); + } + + void _writeNestedRouteGetters( + SourcedEntry entrypoint, + Map> routes, + ) { + final topLevelPaths = + routes.keys + .where((k) => k.isNotEmpty) + .map((k) => k.split('.')[0]) + .toSet(); + + for (final path in topLevelPaths) { + final className = _generateClientClassName(entrypoint, path); + b.writeln( + '\n $className get ${path.toCamelCase()} => $className(client);', + ); + } + } + + void _writeProcedureMethod(RpcProcedure procedure) { + final returnType = switch (procedure.interface) { + AsyncType() => procedure.interface.typeDefinition, + _ => 'Future<${procedure.interface.typeDefinition}>', + }; + + final methodName = procedure.id.split('.').last.toCamelCase(); + + // Write method signature with properly structured parameters + b.write(' $returnType $methodName('); + + // Write positional parameters first + final positional = procedure.parameters.where((p) => !p.isNamed); + b.write(positional.map((p) => p.typedef).join(', ')); + + // If we have named parameters, add them in curly braces + final named = procedure.parameters.where((p) => p.isNamed); + if (named.isNotEmpty) { + if (positional.isNotEmpty) b.write(', '); + b.write('{'); + b.write( + named + .map((p) => '${p.isOptional ? '' : 'required '}${p.typedef}') + .join(', '), + ); + b.write('}'); + } + + b.writeln(') async {'); + + // Build the request body + b.writeln('final positional = [];'); + b.writeln('final named = {};'); + + // Handle positional arguments + for (final param in procedure.parameters.where((p) => !p.isNamed)) { + b.write('positional.add('); + b.write(_serializeType(param.interface, param.name)); + b.writeln(');'); + } + + // Handle named arguments + for (final param in procedure.parameters.where((p) => p.isNamed)) { + b.write('named["${param.name}"] = '); + if (param.isOptional) { + b.write('${param.name} == null ? null : '); + } + b.write(_serializeType(param.interface, param.name)); + b.writeln(';'); + } + + // Construct the request body + b.writeln(); + b.writeln('final body = jsonEncode({'); + b.writeln(' "id": "${procedure.id}",'); + b.writeln(' "params": {'); + b.writeln(' "positional": positional,'); + b.writeln(' "named": named,'); + b.writeln(' },'); + b.writeln('});'); + b.writeln(); + + if (procedure.interface is StreamType) { + // TODO: handle streaming + throw UnimplementedError('Streaming is not supported yet'); + } else { + // POST request + b.write('final result = await client.postRequest("${procedure.id}",'); + // b.write('final result = await client.getRequest("${procedure.id}",'); + b.writeln(' namedParams: named,'); + b.writeln(' positionalParams: positional,'); + b.writeln(');'); + b.writeln(); + + // Return the deserialized result + b.writeln('return ${_deserializeType(procedure.interface, 'result')};'); + } + + b.writeln(' }'); + } + + String _serializeType( + SupportedType type, + String value, { + bool isRoot = true, + }) { + String generateNullCheck(String value, String code) { + return type.isNullable ? '$value == null ? null : $code' : code; + } + + if (type is NullType) { + return 'null'; + } else if (type is UnknownType) { + return generateNullCheck(value, '$value.toJson()'); + } else if (type is ListType || type is SetType || type is IterableType) { + final innerType = (type as SingleTypeArgument).typeArgument; + final mapCode = generateNullCheck( + value, + '$value.map((item) => ${_serializeType(innerType, "item", isRoot: false)})', + ); + return isRoot ? '$mapCode.toList()' : mapCode; + } else if (type is MapType) { + final valueType = type.typeArguments.last; + return generateNullCheck( + value, + '$value.map((key, value) => MapEntry(key, ${_serializeType(valueType, "value", isRoot: false)}))', + ); + } else { + return generateNullCheck( + value, + 'Serializers.instance.get<${type.name}>().serialize($value)', + ); + } + } + + String _deserializeType( + SupportedType type, + String value, { + bool isRoot = true, + }) { + String generateNullCheck(String value, String code) { + return type.isNullable ? '$value == null ? null : $code' : code; + } + + if (type is NullType) { + return 'null'; + } else if (type is UnknownType) { + return generateNullCheck( + value, + 'i${type.importId}.${type.name}.fromJson($value)', + ); + } else if (type is ListType || type is SetType || type is IterableType) { + final innerType = (type as SingleTypeArgument).typeArgument; + final mapCode = generateNullCheck( + value, + '($value as List).map((item) => ${_deserializeType(innerType, "item", isRoot: false)})', + ); + return isRoot ? '$mapCode.toList()' : mapCode; + } else if (type is MapType) { + final valueType = type.typeArguments.last; + return generateNullCheck( + value, + '($value as Map).map((key, value) => MapEntry(key, ${_deserializeType(valueType, "value", isRoot: false)}))', + ); + } else { + return generateNullCheck( + value, + 'Serializers.instance.get<${type.name}>().deserialize($value)', + ); + } + } +} + +extension on SourcedEntry { + String get className => name.toPascalCase(); +} diff --git a/packages/globe_functions/lib/src/build/writers/server_writer.dart b/packages/globe_functions/lib/src/build/writers/server_writer.dart new file mode 100644 index 00000000..e752d830 --- /dev/null +++ b/packages/globe_functions/lib/src/build/writers/server_writer.dart @@ -0,0 +1,283 @@ +import 'package:globe_functions/src/build/writers/base_writer.dart'; +import 'package:globe_functions/src/spec/rpc_procedure.dart'; +import 'package:globe_functions/src/spec/sourced_procedure.dart'; +import 'package:globe_functions/src/spec/types.dart'; + +class ServerWriter extends BaseWriter { + ServerWriter({required super.imports, required super.entrypoints}); + + @override + String write() { + b.writeln("import 'dart:convert';"); + b.writeln("import 'package:shelf/shelf.dart';"); + b.writeln("import 'package:globe_functions/src/shelf_rpc.dart';"); + b.writeln("import 'package:globe_functions/src/spec/serializer.dart';"); + b.writeln( + "import 'package:globe_functions/src/server/request_params.dart';", + ); + b.writeln( + "import 'package:globe_functions/src/server/request_context.dart';", + ); + b.writeln("import 'package:globe_functions/src/server/sse_response.dart';"); + + // Add imports with unique import indexes + b.write(writeImports()); + + for (final entrypoint in entrypoints) { + b.writeln('final ${entrypoint.name} = Pipeline()'); + b.writeln(' .addHandler((Request request) async {'); + b.writeln(' final params = await RequestParams.fromRequest(request);'); + b.writeln(' final positional = params.positional;'); + b.writeln(' final named = params.named;'); + + // Import the entrypoint from the users library. + b.writeln( + ' final _${entrypoint.name} = i${entrypoint.importId}.${entrypoint.name};', + ); + + // Create a pipeline and add middleware for the entrypoint. + b.writeln(' Pipeline pipeline = Pipeline();'); + b.writeln(); + b.writeln(' for (final m in _${entrypoint.name}.middleware) {'); + b.writeln(' pipeline = pipeline.addMiddleware(m);'); + b.writeln(' }'); + + // Add switch statement for procedures. + b.writeln(); + b.writeln(' switch (params.id) {'); + + for (final procedure in entrypoint.procedures) { + b.writeln(' case "${procedure.id}":'); + + final parts = procedure.id.split('.'); + + // Get the procedure from the entrypoint - if its nested, get the nested procedure. + if (parts.length == 1) { + b.writeln( + 'final procedure = _${entrypoint.name}.routes[#${parts[0]}] as ExecutedProcedure;', + ); + b.writeln('final middleware = procedure.middleware;'); + } else { + var i = 0; + for (final part in parts) { + final isLast = i == parts.length - 1; + if (isLast) { + final prevVar = + i == 0 + ? '_${entrypoint.name}' + : '_${parts.take(i).join('_')}'; + b.writeln( + 'final procedure = $prevVar.routes[#$part] as ExecutedProcedure;', + ); + b.writeln('final middleware = procedure.middleware;'); + } else { + final prevVar = + i == 0 + ? '_${entrypoint.name}' + : '_${parts.take(i).join('_')}'; + final nextVar = '_${parts.take(i + 1).join('_')}'; + b.writeln('final $nextVar = $prevVar.routes[#$part] as Router;'); + } + i++; + } + } + + // Apply middleware to the pipeline. + b.writeln(); + b.writeln('for (final m in middleware) {'); + b.writeln(' pipeline = pipeline.addMiddleware(m);'); + b.writeln('}'); + + // Add handler to the pipeline. + b.writeln(); + b.writeln('return pipeline.addHandler((request) async {'); + b.writeln(_buildFunctionHandler(procedure)); + b.writeln('})(request);'); + } + + // Missing procedure case. + b.writeln(' default:'); + b.writeln(' return Response.notFound("Unknown procedure");'); + b.writeln(' }'); + + b.writeln(' });'); + } + + return formatter.format(b.toString()); + } + + String _buildFunctionHandler(RpcProcedure procedure) { + final b = StringBuffer(); + final positional = procedure.parameters.where((p) => p.isPositional); + final named = procedure.parameters.where((p) => p.isNamed); + + b.writeln('final fn = procedure.fn as ${procedure.typedef};'); + b.writeln('final ctx = RequestContext();'); + + // Build each positional parameter in order. + var p = 0; + for (final param in positional) { + b.write('final p$p = '); + + if (param.interface is RequestContextType) { + // Do nothing. + } else if (param.isOptional) { + b.write('positional[$p] == null ? null : '); + } else { + b.write('positional[$p] = '); + } + + if (param.interface is RequestContextType) { + b.write('ctx;'); + } else if (param.interface is UnknownType) { + b.write( + '${param.interface.typeDefinition}.fromJson(positional[$p] as Map);', + ); + } else { + b.write( + 'Serializers.instance.get<${param.interface.name}>().deserialize(positional[$p]);', + ); + } + p++; + } + + // Build each named parameter in order. + var n = 0; + for (final param in named) { + b.write('final n$n = '); + + if (param.interface is RequestContextType) { + // Do nothing. + } else if (param.isOptional) { + b.write('named["${param.name}"] == null ? null : '); + } else { + b.write('named["${param.name}"] = '); + } + + if (param.interface is RequestContextType) { + b.write('ctx;'); + } else if (param.interface is UnknownType) { + b.write( + '${param.interface.typeDefinition}.fromJson(named["${param.name}"]);', + ); + } else { + b.write( + 'Serializers.instance.get<${param.interface.name}>().deserialize(named["${param.name}"]);', + ); + } + n++; + } + + b.writeln(''); + b.write('final result = '); + + switch (procedure.interface) { + case FutureType(): + case FutureOrType(): + b.write('await fn('); + default: + b.write('fn('); + break; + } + + // Add positional parameters first. + for (var i = 0; i < positional.length; i++) { + final param = positional.elementAt(i); + b.writeln('p$i'); + if (param.defaultValue != null) { + b.write(' ?? ${param.defaultValue}'); + } + b.writeln(','); + } + + for (var i = 0; i < named.length; i++) { + final param = named.elementAt(i); + b.write('${param.name}: n$i'); + if (param.defaultValue != null) { + b.write(' ?? ${param.defaultValue}'); + } + b.writeln(','); + } + b.writeln(');'); + + final returnType = switch (procedure.interface) { + FutureType t => t.typeArgument, + FutureOrType t => t.typeArgument, + _ => procedure.interface, + }; + + // Helper function to generate serialization code for a type + String serializeType( + SupportedType type, + String value, { + bool isRoot = true, + }) { + String generateNullCheck(String value, String code) { + return type.isNullable ? '$value == null ? null : $code' : code; + } + + if (type is NullType) { + return 'null'; + } else if (type is UnknownType) { + return generateNullCheck(value, '$value.toJson()'); + } else if (type is ListType || type is SetType || type is IterableType) { + final innerType = (type as SingleTypeArgument).typeArgument; + final mapCode = generateNullCheck( + value, + '$value.map((item) => ${serializeType(innerType, "item", isRoot: false)})', + ); + return isRoot ? '$mapCode.toList()' : mapCode; + } else if (type is MapType) { + final valueType = type.typeArguments.last; + return generateNullCheck( + value, + '$value.map((key, value) => MapEntry(key, ${serializeType(valueType, "value", isRoot: false)}))', + ); + } else { + return generateNullCheck( + value, + 'Serializers.instance.get<${type.name}>().serialize($value)', + ); + } + } + + if (returnType is StreamType) { + final innerType = returnType.typeArgument; + + // If the return type is nullable, we still need to return a SSE + // response, so instead we just create an empty stream. + b.write('final stream = '); + if (returnType.isNullable) { + b.write('result ?? Stream<${innerType.typeDefinition}>.empty();'); + } else { + b.write('result;'); + } + + b.writeAll([ + 'final sse = SseResponse<${innerType.typeDefinition}>(', + ' request: request,', + ' stream: stream,', + // Since the return type is still a Stream, we need the "inner" type. + ' serializer: (data) => ${serializeType(innerType, 'data')},', + ');', + '', + 'return sse.response();', + ], '\n'); + return b.toString(); + } + + // Serialize the result. + b.write('final serialized = '); + + if (returnType.isNullable) { + b.write('result == null ? null : '); + } + + b.writeln('${serializeType(returnType, 'result')};'); + + // Return the result as a JSON response. + b.writeln('return Response.ok(jsonEncode({ "result": serialized }));'); + + return b.toString(); + } +} diff --git a/packages/globe_functions/lib/src/client/http_client.dart b/packages/globe_functions/lib/src/client/http_client.dart new file mode 100644 index 00000000..e96f5b64 --- /dev/null +++ b/packages/globe_functions/lib/src/client/http_client.dart @@ -0,0 +1,5 @@ +import 'package:http/http.dart' as http; + +http.Client createHttpClient() { + return http.Client(); +} diff --git a/packages/globe_functions/lib/src/client/http_client.web.dart b/packages/globe_functions/lib/src/client/http_client.web.dart new file mode 100644 index 00000000..584c1175 --- /dev/null +++ b/packages/globe_functions/lib/src/client/http_client.web.dart @@ -0,0 +1,6 @@ +import 'package:http/browser_client.dart' as http; +import 'package:http/http.dart' as http; + +http.Client createHttpClient() { + return http.BrowserClient()..withCredentials = true; +} diff --git a/packages/globe_functions/lib/src/client/rpc_http_client.dart b/packages/globe_functions/lib/src/client/rpc_http_client.dart new file mode 100644 index 00000000..5b332980 --- /dev/null +++ b/packages/globe_functions/lib/src/client/rpc_http_client.dart @@ -0,0 +1,57 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'http_client.dart' + if (dart.library.html) 'http_client.web.dart' + show createHttpClient; + +abstract class RpcHttpClient { + final http.Client _httpClient; + final Uri _uri; + + RpcHttpClient({required Uri uri, http.Client? client}) + : _uri = uri, + _httpClient = client ?? createHttpClient(); + + Future _handleResponse(http.Response response) async { + if (response.statusCode != 200) { + throw Exception("Failed to make RPC call: ${response.statusCode}"); + } + + return jsonDecode(response.body)["result"]; + } + + Future postRequest( + String id, { + required Map namedParams, + required List positionalParams, + }) async { + final body = jsonEncode({ + "id": id, + "params": {"positional": positionalParams, "named": namedParams}, + }); + + final response = await _httpClient.post( + _uri, + body: body, + headers: {"Content-Type": "application/json"}, + ); + + return _handleResponse(response); + } + + Future getRequest( + String id, { + required Map namedParams, + required List positionalParams, + }) async { + final uri = _uri.replace( + queryParameters: { + "id": id, + "named": base64Encode(utf8.encode(jsonEncode(namedParams))), + "positional": base64Encode(utf8.encode(jsonEncode(positionalParams))), + }, + ); + return _handleResponse(await _httpClient.get(uri)); + } +} diff --git a/packages/globe_functions/lib/src/server/request_context.dart b/packages/globe_functions/lib/src/server/request_context.dart new file mode 100644 index 00000000..88512b6d --- /dev/null +++ b/packages/globe_functions/lib/src/server/request_context.dart @@ -0,0 +1 @@ +final class RequestContext {} diff --git a/packages/globe_functions/lib/src/server/request_params.dart b/packages/globe_functions/lib/src/server/request_params.dart new file mode 100644 index 00000000..292a4481 --- /dev/null +++ b/packages/globe_functions/lib/src/server/request_params.dart @@ -0,0 +1,30 @@ +import 'dart:convert'; + +import 'package:shelf/shelf.dart' show Request; + +final class RequestParams { + static Future fromRequest(Request request) async { + switch (request.method) { + case 'GET': + final Map params = {}; + final queryParams = request.url.queryParameters; + params['id'] = queryParams['id']; + params['named'] = {}; + params['positional'] = []; + return RequestParams._(params); + case 'POST': + final body = await request.readAsString(); + final json = jsonDecode(body) as Map; + return RequestParams._(json); + default: + throw Exception('Unsupported method: ${request.method}'); + } + } + + final Map parms; + RequestParams._(this.parms); + + String get id => parms['id'] as String; + Map get named => parms['named'] ?? {}; + List get positional => parms['positional'] ?? []; +} diff --git a/packages/globe_functions/lib/src/server/sse_response.dart b/packages/globe_functions/lib/src/server/sse_response.dart new file mode 100644 index 00000000..cbe0bbdf --- /dev/null +++ b/packages/globe_functions/lib/src/server/sse_response.dart @@ -0,0 +1,48 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:shelf/shelf.dart'; + +class SseResponse { + SseResponse({ + required this.request, + required this.stream, + required this.serializer, + }); + + late StreamSubscription subscription; + final Request request; + final Stream stream; + final Function(T event) serializer; + + Response response() { + // Create a new StreamController to manage the SSE events + final controller = StreamController(); + + subscription = stream.listen( + (data) { + controller.add('data: ${jsonEncode({"result": serializer(data)})}\n\n'); + }, + onDone: () async { + await controller.close(); + }, + onError: (error, stackTrace) async { + controller.add( + 'event: error\ndata: ${jsonEncode({"error": error.toString()})}\n\n', + ); + // TODO: Should we close the controller here? + }, + ); + + return Response.ok( + controller.stream.transform(utf8.encoder), + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + 'Connection': 'keep-alive', + }, + ); + } +} diff --git a/packages/globe_functions/lib/src/shelf_rpc.dart b/packages/globe_functions/lib/src/shelf_rpc.dart new file mode 100644 index 00000000..97fe6780 --- /dev/null +++ b/packages/globe_functions/lib/src/shelf_rpc.dart @@ -0,0 +1,62 @@ +import 'package:shelf/shelf.dart'; + +sealed class RouteDefinition {} + +// Update type to use Symbol instead of String +typedef ProcedureRoutes = Map; + +class ShelfRpc { + final List _middleware; + const ShelfRpc([this._middleware = const []]); + + // Add middleware at the root level + ShelfRpc use(Middleware middleware) { + return ShelfRpc([..._middleware, middleware]); + } + + Router router(ProcedureRoutes routes) { + return Router(routes, _middleware); + } + + // Pass root middleware to new procedures + Procedure procedure() => Procedure(_middleware); +} + +// Implement RouteDefinition +class Router implements RouteDefinition { + final ProcedureRoutes routes; + final List middleware; + const Router(this.routes, [this.middleware = const []]); +} + +// Implement RouteDefinition +class ExecutedProcedure implements RouteDefinition { + final List middleware; + final Function fn; + final ProcedureMethod method; + const ExecutedProcedure( + this.middleware, { + required this.fn, + required this.method, + }); +} + +// Implement RouteDefinition +class Procedure implements RouteDefinition { + final List middleware; + const Procedure([this.middleware = const []]); + + Procedure use(Middleware middleware) { + return Procedure([...this.middleware, middleware]); + } + + // Now returns ExecutedProcedure instead of Procedure + ExecutedProcedure exec( + Function fn, { + ProcedureMethod method = ProcedureMethod.post, + }) { + return ExecutedProcedure(middleware, fn: fn, method: method); + } +} + +enum ProcedureMethod { get, post } diff --git a/packages/globe_functions/lib/src/spec/rpc_procedure.dart b/packages/globe_functions/lib/src/spec/rpc_procedure.dart new file mode 100644 index 00000000..68ff7afe --- /dev/null +++ b/packages/globe_functions/lib/src/spec/rpc_procedure.dart @@ -0,0 +1,42 @@ +import 'package:globe_functions/src/spec/rpc_procedure_parameter.dart'; +import 'package:globe_functions/src/spec/types.dart'; + +class RpcProcedure { + final String id; + final SupportedType interface; + final Iterable parameters; + + RpcProcedure({ + required this.id, + required this.interface, + required this.parameters, + }); + + String get typedef { + final b = StringBuffer(); + + final positional = parameters.where((p) => p.isPositional && !p.isOptional); + final optional = parameters.where((p) => p.isPositional && p.isOptional); + final named = parameters.where((p) => p.isNamed); + + b.writeln('${interface.typeDefinition} Function('); + + b.write(positional.map((p) => p.typedef).join(', ')); + + if (optional.isNotEmpty) { + if (positional.isNotEmpty) b.write(', '); + b.write('[${optional.map((p) => p.typedef).join(', ')}]'); + } + + if (named.isNotEmpty) { + if (positional.isNotEmpty || optional.isNotEmpty) b.write(', '); + b.write( + '{${named.map((p) => '${p.isRequired ? 'required ' : ''}${p.typedef}').join(', ')}}', + ); + } + + b.writeln(')'); + + return b.toString(); + } +} diff --git a/packages/globe_functions/lib/src/spec/rpc_procedure_parameter.dart b/packages/globe_functions/lib/src/spec/rpc_procedure_parameter.dart new file mode 100644 index 00000000..cf4e23cc --- /dev/null +++ b/packages/globe_functions/lib/src/spec/rpc_procedure_parameter.dart @@ -0,0 +1,29 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:globe_functions/globe_functions.dart'; +import 'package:globe_functions/src/spec/types.dart'; +import 'package:source_gen/source_gen.dart'; + +class RpcProcedureParameter { + final SupportedType interface; + final ParameterElement parameter; + + RpcProcedureParameter(this.interface, this.parameter); + + String get name => parameter.name; + + String? get defaultValue => parameter.defaultValueCode; + + bool get isNamed => parameter.isNamed; + + bool get isPositional => parameter.isPositional; + + bool get isOptional => + parameter.isOptionalNamed || parameter.isOptionalPositional; + + bool get isRequired => + parameter.isRequiredNamed || parameter.isRequiredPositional; + + String get typedef { + return '${interface.typeDefinition}${isOptional ? '?' : ''} $name'; + } +} diff --git a/packages/globe_functions/lib/src/spec/serializer.dart b/packages/globe_functions/lib/src/spec/serializer.dart new file mode 100644 index 00000000..99044659 --- /dev/null +++ b/packages/globe_functions/lib/src/spec/serializer.dart @@ -0,0 +1,214 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:analyzer/dart/element/type.dart'; +import 'package:globe_functions/src/spec/serializers/list_serializer.dart'; +import 'package:globe_functions/src/spec/serializers/map_serializer.dart'; +import 'package:globe_functions/src/spec/serializers/set_serializer.dart'; +import 'package:source_gen/source_gen.dart'; + +abstract class Serializer { + const Serializer(); + + /// Creates a new serializer with the given serialize and deserialize functions + static Serializer create({ + required Object? Function(T value) serialize, + required T Function(W value) deserialize, + }) => _FunctionSerializer( + serializeFn: serialize, + deserializeFn: deserialize, + ); + + /// Serializes [value] to a JSON-compatible type + /// If T is nullable, value may be null + Object? serialize(T value); + + /// Deserializes [value] from a JSON-compatible type + /// If value is null and T is nullable, returns null + /// If value is null and T is non-nullable, throws an error + T deserialize(Object? value); + + /// Helper to verify wire type and throw clear errors + W assertWireType(Object? value) { + if (value == null) { + if (null is T) { + throw Exception('Cannot convert null to wire type $W for $runtimeType'); + } + throw Exception( + 'Cannot deserialize null to non-nullable type ${T} for $runtimeType', + ); + } + if (value is! W) { + throw Exception( + 'Expected wire type $W but got ${value.runtimeType} for $runtimeType', + ); + } + return value as W; + } +} + +class _FunctionSerializer + extends Serializer { + const _FunctionSerializer({ + required this.serializeFn, + required this.deserializeFn, + }); + + final Object? Function(T value) serializeFn; + final T Function(W value) deserializeFn; + + @override + Object? serialize(T value) => value == null ? null : serializeFn(value); + + @override + T deserialize(Object? value) { + if (value == null) { + if (null is T) { + return null as T; + } + throw Exception( + 'Cannot deserialize null to non-nullable type ${T} for $runtimeType', + ); + } + return deserializeFn(assertWireType(value)); + } +} + +class Serializers { + static final _instance = Serializers._(); + static Serializers get instance => _instance; + + static const bigInt = TypeChecker.fromRuntime(BigInt); + static const dateTime = TypeChecker.fromRuntime(DateTime); + static const duration = TypeChecker.fromRuntime(Duration); + static const regExp = TypeChecker.fromRuntime(RegExp); + static const uri = TypeChecker.fromRuntime(Uri); + static const uriData = TypeChecker.fromRuntime(UriData); + static const uint8List = TypeChecker.fromRuntime(Uint8List); + static const list = TypeChecker.fromRuntime(List); + static const map = TypeChecker.fromRuntime(Map); + + final _serializers = {}; + final _typeTokenSerializers = {}; + + Serializers._() { + // Register built-in serializers + register( + Serializer.create( + serialize: (v) => v, + deserialize: (v) => v, + ), + ); + register( + Serializer.create( + serialize: (v) => v, + deserialize: (v) => v.toInt(), + ), + ); + register( + Serializer.create( + serialize: (v) => v, + deserialize: (v) => v.toDouble(), + ), + ); + register( + Serializer.create(serialize: (v) => v, deserialize: (v) => v), + ); + register( + Serializer.create( + serialize: (v) => v.toString(), + deserialize: (v) => BigInt.parse(v), + ), + ); + register( + Serializer.create( + serialize: (v) => v.toIso8601String(), + deserialize: (v) => DateTime.parse(v), + ), + ); + register( + Serializer.create( + serialize: (v) => v.inMicroseconds.toString(), + deserialize: (v) => Duration(microseconds: int.parse(v)), + ), + ); + register( + Serializer.create( + serialize: (v) => v.pattern, + deserialize: (v) => RegExp(v), + ), + ); + register( + Serializer.create( + serialize: (v) => v.toString(), + deserialize: (v) => Uri.parse(v), + ), + ); + register( + Serializer.create( + serialize: (v) => v.toString(), + deserialize: (v) => UriData.parse(v), + ), + ); + register( + Serializer.create( + serialize: (v) => base64.encode(v), + deserialize: (v) => v.isEmpty ? Uint8List(0) : base64.decode(v), + ), + ); + register(const MapSerializer()); + register(const ListSerializer()); + register(const SetSerializer()); + } + + void register(Serializer serializer) { + _serializers[T] = serializer; + // Also register with type token for generic types + _typeTokenSerializers['$T'] = serializer; + } + + Serializer? getFromType(DartType type) { + final serializer = switch (type) { + DartType _ when type.isDartCoreString => get(), + DartType _ when type.isDartCoreInt => get(), + DartType _ when type.isDartCoreDouble => get(), + DartType _ when type.isDartCoreBool => get(), + DartType _ when type.isDartCoreMap => get(), + DartType _ when type.isDartCoreList => get(), + DartType _ when type.isDartCoreSet => get(), + DartType _ when type.isDartAsyncFuture => null, + DartType _ when type.isDartAsyncStream => null, + DartType _ when type.isDartCoreIterable => null, + _ => _typeTokenSerializers[type.getDisplayString(withNullability: true)], + }; + return serializer as Serializer?; + } + + Serializer get() { + // Handle collection types by falling back to base type + if (T.toString().startsWith('Map<') || + T.toString().startsWith('List<') || + T.toString().startsWith('Set<')) { + final baseType = switch (T.toString().split('<').first) { + 'Map' => Map, + 'List' => List, + 'Set' => Set, + _ => throw Exception('Unsupported collection type: $T'), + }; + final serializer = _serializers[baseType]; + if (serializer == null) { + throw Exception('No serializer registered for type $baseType'); + } + return serializer as Serializer; + } + + final serializer = _serializers[T] ?? _typeTokenSerializers['$T']; + if (serializer == null) { + throw Exception('No serializer registered for type $T'); + } + return serializer as Serializer; + } + + T deserialize(Object? value) => get().deserialize(value); + Object? serialize(T value) => get().serialize(value); +} diff --git a/packages/globe_functions/lib/src/spec/serializers/core_serializer.dart b/packages/globe_functions/lib/src/spec/serializers/core_serializer.dart new file mode 100644 index 00000000..8d424759 --- /dev/null +++ b/packages/globe_functions/lib/src/spec/serializers/core_serializer.dart @@ -0,0 +1 @@ +// This file can be deleted as we now use Serializer.create for core types diff --git a/packages/globe_functions/lib/src/spec/serializers/list_serializer.dart b/packages/globe_functions/lib/src/spec/serializers/list_serializer.dart new file mode 100644 index 00000000..8e20f48b --- /dev/null +++ b/packages/globe_functions/lib/src/spec/serializers/list_serializer.dart @@ -0,0 +1,37 @@ +import 'dart:convert'; + +import 'package:globe_functions/src/spec/serializer.dart'; + +final class ListSerializer extends Serializer { + const ListSerializer(); + + @override + Object? serialize(List value) { + return value + .map( + (item) => + item == null + ? null + : item is List || item is Map + ? serialize(item) + : item, + ) + .toList(); + } + + @override + List deserialize(Object? value) { + if (value == null) return []; + final list = assertWireType(value); + return list + .map( + (item) => + item == null + ? null + : item is List || item is Map + ? deserialize(item) + : item, + ) + .toList(); + } +} diff --git a/packages/globe_functions/lib/src/spec/serializers/map_serializer.dart b/packages/globe_functions/lib/src/spec/serializers/map_serializer.dart new file mode 100644 index 00000000..6f69116f --- /dev/null +++ b/packages/globe_functions/lib/src/spec/serializers/map_serializer.dart @@ -0,0 +1,37 @@ +import 'dart:convert'; + +import 'package:globe_functions/src/spec/serializer.dart'; + +final class MapSerializer extends Serializer { + const MapSerializer(); + + @override + Object? serialize(Map value) { + return value.map( + (key, val) => MapEntry( + key, + val == null + ? null + : val is Map || val is List + ? serialize(val) + : val, + ), + ); + } + + @override + Map deserialize(Object? value) { + if (value == null) return {}; + final map = assertWireType(value); + return map.map( + (key, val) => MapEntry( + key, + val == null + ? null + : val is Map || val is List + ? deserialize(val) + : val, + ), + ); + } +} diff --git a/packages/globe_functions/lib/src/spec/serializers/set_serializer.dart b/packages/globe_functions/lib/src/spec/serializers/set_serializer.dart new file mode 100644 index 00000000..1c83cf60 --- /dev/null +++ b/packages/globe_functions/lib/src/spec/serializers/set_serializer.dart @@ -0,0 +1,37 @@ +import 'dart:convert'; + +import 'package:globe_functions/src/spec/serializer.dart'; + +final class SetSerializer extends Serializer { + const SetSerializer(); + + @override + Object? serialize(Set value) { + return value + .map( + (item) => + item == null + ? null + : item is List || item is Map + ? serialize(item) + : item, + ) + .toList(); + } + + @override + Set deserialize(Object? value) { + if (value == null) return {}; + final list = assertWireType(value); + return list + .map( + (item) => + item == null + ? null + : item is List || item is Map + ? deserialize(item) + : item, + ) + .toSet(); + } +} diff --git a/packages/globe_functions/lib/src/spec/sourced_entry.dart b/packages/globe_functions/lib/src/spec/sourced_entry.dart new file mode 100644 index 00000000..775ba447 --- /dev/null +++ b/packages/globe_functions/lib/src/spec/sourced_entry.dart @@ -0,0 +1,18 @@ +import 'package:globe_functions/src/spec/rpc_procedure.dart'; + +class SourcedEntry { + SourcedEntry({ + required this.importId, + required this.name, + required this.procedures, + }); + + /// The import ID of the entry. + final int importId; + + /// The name of the entry. + final String name; + + /// The procedures of the entry. + final List procedures; +} diff --git a/packages/globe_functions/lib/src/spec/sourced_imports.dart b/packages/globe_functions/lib/src/spec/sourced_imports.dart new file mode 100644 index 00000000..85dd50ef --- /dev/null +++ b/packages/globe_functions/lib/src/spec/sourced_imports.dart @@ -0,0 +1,17 @@ +class SourcedImports { + final imports = {}; + + /// Registers a URI and returns its index in the imports set. + /// If the URI is already registered, returns its existing index. + int register(Uri uri) { + final existingIndex = imports.lookup(uri); + if (existingIndex != null) { + return imports.toList().indexOf(uri); + } + imports.add(uri); + return imports.length - 1; + } + + /// Gets all registered imports + Set call() => Set.unmodifiable(imports); +} diff --git a/packages/globe_functions/lib/src/spec/sourced_parameter.dart b/packages/globe_functions/lib/src/spec/sourced_parameter.dart new file mode 100644 index 00000000..f9964e32 --- /dev/null +++ b/packages/globe_functions/lib/src/spec/sourced_parameter.dart @@ -0,0 +1,64 @@ +// import 'package:globe_functions/src/spec/sourced_type.dart'; + +// class SourcedParameter { +// SourcedParameter({ +// required this.name, +// required this.type, +// required this.defaultValue, +// required this.isNamed, +// required this.isPositional, +// required this.isOptional, +// required this.isRequired, +// required this.isRequestContext, +// }); + +// /// The name of the parameter. +// final String name; + +// /// The type of the parameter. +// final SourcedType type; + +// /// The default value of the parameter. +// final String? defaultValue; + +// /// Whether the parameter is named. +// final bool isNamed; + +// /// Whether the parameter is positional. +// final bool isPositional; + +// /// Whether the parameter is optional. +// final bool isOptional; + +// /// Whether the parameter is required. +// final bool isRequired; + +// /// Whether the parameter is the request context. +// final bool isRequestContext; + +// Map toJson() { +// return { +// 'name': name, +// 'type': type.toJson(), +// 'defaultValue': defaultValue, +// 'isNamed': isNamed, +// 'isPositional': isPositional, +// 'isOptional': isOptional, +// 'isRequired': isRequired, +// 'isRequestContext': isRequestContext, +// }; +// } + +// factory SourcedParameter.fromJson(Map json) { +// return SourcedParameter( +// name: json['name'] as String, +// type: SourcedType.fromJson(json['type'] as Map), +// defaultValue: json['defaultValue'] as String?, +// isNamed: json['isNamed'] as bool, +// isPositional: json['isPositional'] as bool, +// isOptional: json['isOptional'] as bool, +// isRequired: json['isRequired'] as bool, +// isRequestContext: json['isRequestContext'] as bool, +// ); +// } +// } diff --git a/packages/globe_functions/lib/src/spec/sourced_procedure.dart b/packages/globe_functions/lib/src/spec/sourced_procedure.dart new file mode 100644 index 00000000..a420f667 --- /dev/null +++ b/packages/globe_functions/lib/src/spec/sourced_procedure.dart @@ -0,0 +1,67 @@ +// import 'package:globe_functions/src/spec/sourced_parameter.dart'; +// import 'package:globe_functions/src/spec/sourced_type.dart'; + +// class SourcedProcedure { +// SourcedProcedure({ +// required this.id, +// required this.type, +// required this.parameters, +// }); + +// /// The RPC ID of the procedure. +// final String id; + +// /// The type of the procedure. +// final SourcedType type; + +// /// The parameters of the procedure. +// final List parameters; + +// String get typeDefinition { +// final b = StringBuffer(); + +// // Write the return type def (e.g. String, Future, etc.) +// b.write(type.typeDefinition); + +// b.write(' Function('); +// final positional = parameters.where((p) => p.isPositional && !p.isOptional); +// final optional = parameters.where((p) => p.isPositional && p.isOptional); +// final named = parameters.where((p) => p.isNamed); + +// b.write(positional.map((p) => p.type.typeDefinition).join(', ')); + +// if (optional.isNotEmpty) { +// if (positional.isNotEmpty) b.write(', '); +// b.write('[${optional.map((p) => p.type.typeDefinition).join(', ')}]'); +// } + +// if (named.isNotEmpty) { +// if (positional.isNotEmpty || optional.isNotEmpty) b.write(', '); +// b.write( +// '{${named.map((p) => '${p.isRequired ? "required " : ""}${p.type.typeDefinition} ${p.name}').join(', ')}}', +// ); +// } +// b.write(')'); + +// return b.toString(); +// } + +// Map toJson() { +// return { +// 'id': id, +// 'type': type.toJson(), +// 'parameters': parameters.map((p) => p.toJson()).toList(), +// }; +// } + +// static SourcedProcedure fromJson(Map json) { +// return SourcedProcedure( +// id: json['id'] as String, +// type: SourcedType.fromJson(json['type'] as Map), +// parameters: +// (json['parameters'] as List) +// .map((p) => SourcedParameter.fromJson(p as Map)) +// .toList(), +// ); +// } +// } diff --git a/packages/globe_functions/lib/src/spec/sourced_serializers.dart b/packages/globe_functions/lib/src/spec/sourced_serializers.dart new file mode 100644 index 00000000..51ad0517 --- /dev/null +++ b/packages/globe_functions/lib/src/spec/sourced_serializers.dart @@ -0,0 +1,102 @@ +// import 'package:analyzer/dart/element/nullability_suffix.dart'; +// import 'package:analyzer/dart/element/type.dart'; +// import 'package:globe_functions/src/build/source_rpc_builder.dart'; +// import 'package:globe_functions/src/spec/serializer.dart'; +// import 'package:globe_functions/src/spec/sourced_type.dart'; + +// class SourcedSerializers { +// final _types = {}; + +// SourcedSerializers(); + +// void register(SourcedType info) { +// // Get the full typedef of the type, e.g. `Map` +// final typedef = info.typeDefinition; + +// // Don't register the same type twice. +// if (_types.containsKey(typedef)) { +// return; +// } + +// // Register the type. +// _types[typedef] = info; +// } + +// String generateRegistrationCode() { +// final b = StringBuffer(); +// b.writeln('void registerSerializers() {'); + +// for (final info in _types.values) { +// final typedef = info.typeDefinition; +// b.writeln('Serializers.instance.register<$typedef, String>('); +// b.writeln(' Serializer.create<$typedef}>('); + +// if (info.kind == SourcedTypeKind.list) { +// final innerType = info.typeArguments.first; +// final typedef = innerType.typeDefinition; + +// // Serialize the list. +// b.writeln('serialize: ($typedef value) => jsonEncode('); +// b.writeln(' value.map((i) => i.toJson()).toList(),'); +// b.writeln('),'); + +// b.writeln(' deserialize: (value) {'); +// if (info.isNullable) { +// b.writeln(' if (value == null) {'); +// b.writeln( +// ' throw Exception("Cannot deserialize null value");', +// ); +// b.writeln(' }'); +// } +// b.writeln( +// ' final json = value is String ? jsonDecode(value) : value;', +// ); +// b.writeln(' return (json as List)'); +// b.writeln(' .map((item) => $innerTypeName.fromJson(item))'); +// b.writeln(' .toList();'); +// b.writeln(' }'); +// } else if (info.kind == SourcedTypeKind.set) { +// b.writeln(' serialize: (value) => jsonEncode(value.toJson()),'); +// } else { +// b.writeln(' serialize: (value) => jsonEncode(value.toJson()),'); +// } + +// b.writeln(' serialize: (value) => jsonEncode(value.toJson()),'); +// b.writeln(' deserialize: (value) {'); +// b.writeln(' if (value == null) {'); +// b.writeln(' throw Exception("Cannot deserialize null value");'); +// b.writeln(' }'); +// b.writeln( +// ' final json = value is String ? jsonDecode(value) : value;', +// ); +// b.writeln(' return $typedef.fromJson(json);'); +// b.writeln(' }'); + +// b.writeln(' )'); +// b.writeln(');'); +// } + +// b.writeln('}'); +// return b.toString(); +// } + +// Map toJson() { +// return { +// 'serializers': { +// for (final entry in _types.entries) entry.key: entry.value.toJson(), +// }, +// }; +// } + +// factory SourcedSerializers.fromJson(Map json) { +// final serializers = SourcedSerializers(); +// final map = json['serializers'] as Map; + +// for (final entry in map.entries) { +// final info = SourcedType.fromJson(entry.value as Map); +// serializers._types[entry.key] = info; +// } + +// return serializers; +// } +// } diff --git a/packages/globe_functions/lib/src/spec/sourced_type.dart b/packages/globe_functions/lib/src/spec/sourced_type.dart new file mode 100644 index 00000000..e6978b8f --- /dev/null +++ b/packages/globe_functions/lib/src/spec/sourced_type.dart @@ -0,0 +1,337 @@ +// import 'package:analyzer/dart/element/element.dart'; +// import 'package:analyzer/dart/element/nullability_suffix.dart'; +// import 'package:analyzer/dart/element/type.dart'; +// import 'package:globe_functions/src/build/source_rpc_builder.dart'; +// import 'package:globe_functions/src/spec/serializer.dart'; +// import 'package:globe_functions/src/spec/sourced_imports.dart'; +// import 'package:source_gen/source_gen.dart'; + +// enum SourcedTypeKind { +// /// Whether the type is a [Future] +// future(name: 'Future', supported: true, streamable: false), + +// /// Whether the type is a [FutureOr] +// futureOr(name: 'FutureOr', supported: true, streamable: false), + +// /// Whether the type is a [Stream] +// stream(name: 'Stream', supported: true, streamable: true), + +// /// Whether the type is an [Iterable] +// iterable(name: 'Iterable', supported: true, streamable: true), + +// /// Whether the type is a [List] +// list(name: 'List', supported: true, streamable: false), + +// /// Whether the type is a [Set] +// set(name: 'Set', supported: true, streamable: false), + +// /// Whether the type is a [Map] +// map(name: 'Map', supported: true, streamable: false), + +// /// Whether the type is a [Null] +// null_(name: 'Null', supported: true, streamable: false), + +// /// Whether the type is a [Record] +// record(name: 'Record', supported: false, streamable: false), + +// /// Whether the type is a core type, like [int], [String], [bool], etc. +// core(name: null, supported: true, streamable: false), + +// /// Whether the type is a custom class/interface type. +// unknown(name: null, supported: true, streamable: false), + +// /// Whether the type is void +// void_(name: 'void', supported: true, streamable: false), + +// /// Whether the type is dynamic +// dynamic_(name: 'dynamic', supported: false, streamable: false), + +// /// Whether the type is Never +// never(name: 'Never', supported: false, streamable: false), + +// /// Whether the type is a Function +// function(name: 'Function', supported: false, streamable: false), + +// /// Whether the type is an Object +// object(name: 'Object', supported: false, streamable: false), + +// /// Whether the type is an Enum +// enum_(name: 'Enum', supported: false, streamable: false); + +// final String? name; +// final bool supported; +// final bool streamable; + +// const SourcedTypeKind({ +// required this.name, +// required this.supported, +// this.streamable = false, +// }); +// } + +// class SourcedType { +// final String type; +// final SourcedTypeKind? kind; +// final bool isNullable; +// final int? importId; +// final List typeArguments; + +// SourcedType({ +// required this.type, +// required this.isNullable, +// this.kind, +// this.importId, +// this.typeArguments = const [], +// }); + +// factory SourcedType.fromDartType({ +// required SourcedImports imports, +// // required SourcedSerializers serializers, +// required LibraryElement library, +// required DartType type, +// int depth = 0, +// }) { +// final kind = switch (type) { +// // Future, FutureOr +// InterfaceType type +// when type.isDartAsyncFuture || type.isDartAsyncFutureOr => +// SourcedTypeKind.future, +// // Stream +// InterfaceType type when type.isDartAsyncStream => SourcedTypeKind.stream, +// // Iterable +// InterfaceType type when type.isDartCoreIterable => +// SourcedTypeKind.iterable, +// // List +// InterfaceType type when type.isDartCoreList => SourcedTypeKind.list, +// // Set +// InterfaceType type when type.isDartCoreSet => SourcedTypeKind.set, +// // Map +// InterfaceType type when type.isDartCoreMap => SourcedTypeKind.map, +// // Enum +// InterfaceType type when type.isDartCoreEnum => SourcedTypeKind.enum_, +// // Record +// InterfaceType type when type.isDartCoreRecord => SourcedTypeKind.record, +// // Null +// InterfaceType type when type.isDartCoreNull => SourcedTypeKind.null_, +// // Enum +// InterfaceType type when type.isDartCoreEnum => SourcedTypeKind.enum_, +// // Object +// InterfaceType type when type.isDartCoreObject => SourcedTypeKind.object, +// // Function +// InterfaceType type when type.isDartCoreFunction => +// SourcedTypeKind.function, +// // Void +// VoidType _ => SourcedTypeKind.void_, +// // Dynamic +// DynamicType _ => SourcedTypeKind.dynamic_, +// // Never +// NeverType _ => SourcedTypeKind.never, +// // Core types +// InterfaceType type +// when type.isDartCoreBool || +// type.isDartCoreDouble || +// type.isDartCoreInt || +// type.isDartCoreString || +// type.isDartCoreNum => +// SourcedTypeKind.core, +// // Custom types +// InterfaceType _ => SourcedTypeKind.unknown, +// // Unknown type, throw an error. +// _ => +// throw InvalidGenerationSourceError( +// 'Unable to determine type kind (${type.runtimeType})', +// element: type.element, +// ), +// }; + +// // If the type is not supported, throw an error. +// if (!kind.supported) { +// throw InvalidGenerationSourceError( +// 'Type is not supported (${type.runtimeType})', +// element: type.element, +// ); +// } + +// if (depth > 0) { +// switch (kind) { +// case SourcedTypeKind.future: +// case SourcedTypeKind.futureOr: +// case SourcedTypeKind.stream: +// throw InvalidGenerationSourceError( +// 'Nested async types are not supported', +// element: type.element, +// ); +// case _: +// break; +// } +// } + +// final interface = type as InterfaceType; + +// if (kind == SourcedTypeKind.map) { +// // First type argument must be a string. +// if (!interface.typeArguments.first.isDartCoreString) { +// throw InvalidGenerationSourceError( +// 'Map key type must be a String', +// element: type.element, +// ); +// } +// } + +// // Get the serializer for the type, e.g. String, DateTime etc. +// // Get the serializer for the type, e.g. String, DateTime etc. +// final serializer = Serializers.instance.getFromType(interface); + +// int? importId; + +// // If we have no serializer, we need to register an import and assert +// // that the type is serializable. +// if (serializer == null) { +// // If the type is not serializable, throw an error. +// interface.assertSerializable(library); + +// // Register the import. +// importId = imports.register(interface.element.source.uri); +// } + +// // Compose the type arguments if they exist, e.g. +// // Map -> [String, User] +// final arguments = [ +// for (final type in interface.typeArguments) +// SourcedType.fromDartType( +// imports: imports, +// // serializers: serializers, +// library: library, +// type: type, +// depth: depth + 1, +// ), +// ]; + +// final sourced = SourcedType( +// type: type.getDisplayString(withNullability: false), +// isNullable: type.nullabilitySuffix == NullabilitySuffix.question, +// kind: kind, +// importId: importId, +// typeArguments: arguments, +// ); + +// return sourced; +// } + +// String get typeDefinition { +// final b = StringBuffer(); +// if (importId != null) b.write('i$importId.'); +// b.write(type); + +// if (typeArguments.isNotEmpty) { +// b.write('<'); +// b.write(typeArguments.map((type) => type.typeDefinition).join(', ')); +// b.write('>'); +// } + +// if (isNullable) b.write('?'); +// return b.toString(); +// } + +// factory SourcedType.fromJson(Map json) { +// return SourcedType( +// type: json['type'] as String, +// kind: +// json['kind'] == null +// ? null +// : SourcedTypeKind.values.firstWhere( +// (e) => e.name == json['kind'], +// ), +// isNullable: json['isNullable'] as bool, +// importId: json['importId'] as int?, +// typeArguments: +// (json['typeArguments'] as List) +// .map((arg) => SourcedType.fromJson(arg as Map)) +// .toList(), +// ); +// } + +// Map toJson() => { +// 'type': type, +// 'isNullable': isNullable, +// 'kind': kind?.name, +// 'importId': importId, +// 'typeArguments': typeArguments.map((arg) => arg.toJson()).toList(), +// }; +// } + +// extension on InterfaceType { +// /// Asserts that the type is serializable, by checking for a +// /// `toJson` method and a `fromJson` constructor. +// assertSerializable(LibraryElement library) { +// final toJson = lookUpMethod2('toJson', library); +// final fromJson = lookUpConstructor('fromJson', library); + +// if (toJson == null) { +// throw InvalidGenerationSourceError( +// 'Type is not serializable (missing toJson method)', +// element: element, +// ); +// } + +// if (fromJson == null) { +// throw InvalidGenerationSourceError( +// 'Type is not serializable (missing fromJson constructor)', +// element: element, +// ); +// } +// } +// } + +// // import 'package:analyzer/dart/element/nullability_suffix.dart'; +// // import 'package:analyzer/dart/element/type.dart'; + +// // enum SourcedTypeKind { future, stream, iterable, list, set, map } + +// // class SourcedType { +// // final String type; +// // final bool isNullable; +// // final SourcedTypeKind? kind; +// // final int? importId; + +// // SourcedType({ +// // required this.type, +// // required this.isNullable, +// // required this.kind, +// // required this.importId, +// // }); + +// // factory SourcedType.fromDartType({ +// // required DartType type, +// // SourcedTypeKind? kind, +// // int? importId, +// // }) { +// // return SourcedType( +// // type: type.getDisplayString(withNullability: false), +// // isNullable: type.nullabilitySuffix == NullabilitySuffix.question, +// // kind: kind, +// // importId: importId, +// // ); +// // } + +// // Map toJson() { +// // return { +// // 'type': type, +// // 'isNullable': isNullable, +// // 'kind': kind?.name, +// // 'importId': importId, +// // }; +// // } + +// // factory SourcedType.fromJson(Map json) { +// // return SourcedType( +// // type: json['type'] as String, +// // isNullable: json['isNullable'] as bool, +// // kind: +// // json['kind'] != null +// // ? SourcedTypeKind.values.firstWhere((e) => e.name == json['kind']) +// // : null, +// // importId: json['importId'] as int?, +// // ); +// // } +// // } diff --git a/packages/globe_functions/lib/src/spec/types.dart b/packages/globe_functions/lib/src/spec/types.dart new file mode 100644 index 00000000..757412a8 --- /dev/null +++ b/packages/globe_functions/lib/src/spec/types.dart @@ -0,0 +1,331 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/nullability_suffix.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:globe_functions/globe_functions.dart'; +import 'package:globe_functions/src/spec/serializer.dart'; +import 'package:globe_functions/src/spec/sourced_imports.dart'; +import 'package:source_gen/source_gen.dart'; + +const _requestContextType = TypeChecker.fromRuntime(RequestContext); + +final class UnsupportedType implements Exception { + UnsupportedType(this.type); + final InterfaceType type; + + @override + String toString() { + return 'UnsupportedType: $type'; + } +} + +sealed class SupportedType { + factory SupportedType.fromDartType({ + required DartType type, + required SourcedImports imports, + required LibraryElement library, + }) { + final interface = switch (type) { + InterfaceType type => type, + DynamicType _ => + throw InvalidGenerationSourceError( + 'Cannot return dynamic types.', + element: type.element, + ), + NeverType _ => + throw InvalidGenerationSourceError( + 'Cannot return never types.', + element: type.element, + ), + TypeParameterType _ => + throw InvalidGenerationSourceError( + 'Cannot return generic type parameters directly. Use a concrete type instead.', + element: type.element, + ), + _ => + throw InvalidGenerationSourceError( + 'Unhandled type: ${type} ${type.runtimeType}', + element: type.element, + ), + }; + + final supported = switch (interface) { + InterfaceType type when type.isDartAsyncFuture => FutureType( + type, + imports: imports, + library: library, + ), + InterfaceType type when type.isDartAsyncFutureOr => FutureOrType( + type, + imports: imports, + library: library, + ), + InterfaceType type when type.isDartAsyncStream => StreamType( + type, + imports: imports, + library: library, + ), + InterfaceType type when type.isDartCoreNull => NullType( + type, + imports: imports, + library: library, + ), + InterfaceType type when type.isDartCoreList => ListType( + type, + imports: imports, + library: library, + ), + InterfaceType type when type.isDartCoreSet => SetType( + type, + imports: imports, + library: library, + ), + InterfaceType type when type.isDartCoreMap => MapType( + type, + imports: imports, + library: library, + ), + InterfaceType type when type.isDartCoreIterable => IterableType( + type, + imports: imports, + library: library, + ), + InterfaceType type + when type.isDartCoreBool || + type.isDartCoreDouble || + type.isDartCoreInt || + type.isDartCoreString || + type.isDartCoreNum || + type.element is DateTime => + CoreType(type, imports: imports, library: library), + InterfaceType type + when type.isDartCoreEnum || + type.isDartCoreSymbol || + type.isDartCoreObject || + type.isDartCoreRecord || + type.isDartCoreType || + type.isDartCoreFunction => + throw InvalidGenerationSourceError( + 'RPC does not support type $type', + element: interface.element, + ), + InterfaceType _ => null, + }; + + if (supported == null) { + if (_requestContextType.isExactlyType(interface)) { + return RequestContextType( + interface, + imports: imports, + library: library, + ); + } + + // If the type has a serializer, we can use the CoreType, e.g. DateTime. + if (Serializers.instance.getFromType(interface) != null) { + return CoreType(interface, imports: imports, library: library); + } + + // Otherwise, its unknown (a user defined type). + return UnknownType(interface, imports: imports, library: library); + } + + return supported; + } + + final InterfaceType type; + final SourcedImports imports; + final LibraryElement library; + + SupportedType(this.type, {required this.imports, required this.library}); + + String get name => type.getDisplayString(withNullability: false); + bool get isNullable => type.nullabilitySuffix == NullabilitySuffix.question; + + String get typeDefinition; + + void assertValid() => {}; +} + +final class UnknownType extends MultipleTypeArguments { + UnknownType(super.type, {required super.imports, required super.library}) + : importId = imports.register(type.element.source.uri); + + final int importId; + + @override + void assertValid() { + if (typeArguments.isNotEmpty) { + throw InvalidGenerationSourceError( + 'Unknown type cannot have type arguments', + element: type.element, + ); + } + + assertSerializable(); + } + + void assertSerializable() { + final toJson = type.lookUpMethod2('toJson', library); + final fromJson = type.lookUpConstructor('fromJson', library); + + if (toJson == null || fromJson == null) { + throw InvalidGenerationSourceError( + 'Type is not serializable (missing toJson or fromJson method)', + element: type.element, + ); + } + } + + @override + String get typeDefinition { + return 'i$importId.$name${isNullable ? '?' : ''}'; + } +} + +final class AsyncType extends SingleTypeArgument { + AsyncType(super.type, {required super.imports, required super.library}); +} + +final class SingleTypeArgument extends SupportedType { + SingleTypeArgument( + super.type, { + required super.imports, + required super.library, + }); + SupportedType get typeArgument => SupportedType.fromDartType( + type: type.typeArguments.first, + imports: imports, + library: library, + ); + + @override + void assertValid() { + typeArgument.assertValid(); + } + + @override + String get typeDefinition => '$name${isNullable ? '?' : ''}'; +} + +final class MultipleTypeArguments extends SupportedType { + MultipleTypeArguments( + super.type, { + required super.imports, + required super.library, + }); + Iterable get typeArguments => type.typeArguments.map( + (type) => SupportedType.fromDartType( + type: type, + imports: imports, + library: library, + ), + ); + + @override + String get typeDefinition => + typeArguments.map((t) => t.typeDefinition).join(', '); +} + +final class FutureType extends AsyncType { + FutureType(super.type, {required super.imports, required super.library}); + + @override + String get typeDefinition => + 'Future${isNullable ? '?' : ''}<${typeArgument.typeDefinition}>'; +} + +final class FutureOrType extends AsyncType { + FutureOrType(super.type, {required super.imports, required super.library}); + + @override + String get typeDefinition => + 'FutureOr${isNullable ? '?' : ''}<${typeArgument.typeDefinition}>'; +} + +final class StreamType extends AsyncType { + StreamType(super.type, {required super.imports, required super.library}); + + @override + String get typeDefinition => + 'Stream${isNullable ? '?' : ''}<${typeArgument.typeDefinition}>'; +} + +final class MapType extends MultipleTypeArguments { + MapType(super.type, {required super.imports, required super.library}); + + @override + void assertValid() { + final first = typeArguments.first; + final last = typeArguments.last; + + if (first is! CoreType && !first.type.isDartCoreString) { + throw InvalidGenerationSourceError( + 'Map type must have a string key and a serializable value.', + element: type.element, + ); + } + + return switch (last) { + AsyncType() => + throw InvalidGenerationSourceError( + 'Map type cannot have an async type argument', + element: type.element, + ), + RequestContextType() => null, + SupportedType type => type.assertValid(), + }; + } + + @override + String get typeDefinition => + 'Map${isNullable ? '?' : ''}<${typeArguments.first.typeDefinition}, ${typeArguments.last.typeDefinition}>'; +} + +final class NullType extends SupportedType { + NullType(super.type, {required super.imports, required super.library}); + + @override + String get typeDefinition => 'Null'; +} + +final class ListType extends SingleTypeArgument { + ListType(super.type, {required super.imports, required super.library}); + + @override + String get typeDefinition => + 'List${isNullable ? '?' : ''}<${typeArgument.typeDefinition}>'; +} + +final class SetType extends SingleTypeArgument { + SetType(super.type, {required super.imports, required super.library}); + + @override + String get typeDefinition => + 'Set${isNullable ? '?' : ''}<${typeArgument.typeDefinition}>'; +} + +final class IterableType extends SingleTypeArgument { + IterableType(super.type, {required super.imports, required super.library}); + + @override + String get typeDefinition => + 'Iterable${isNullable ? '?' : ''}<${typeArgument.typeDefinition}>'; +} + +final class CoreType extends SupportedType { + CoreType(super.type, {required super.imports, required super.library}); + + @override + String get typeDefinition => '$name${isNullable ? '?' : ''}'; +} + +final class RequestContextType extends CoreType { + RequestContextType( + super.type, { + required super.imports, + required super.library, + }); + + @override + String get typeDefinition => 'RequestContext'; +} diff --git a/packages/globe_functions/pubspec.yaml b/packages/globe_functions/pubspec.yaml new file mode 100644 index 00000000..295fd13a --- /dev/null +++ b/packages/globe_functions/pubspec.yaml @@ -0,0 +1,26 @@ +name: globe_functions +description: A starting point for Dart libraries or applications. +version: 1.0.0 +# repository: https://github.com/my_org/my_repo + +environment: + sdk: ^3.7.0-323.0.dev + +# Add regular dependencies here. +dependencies: + analyzer: + build_runner: ^2.4.14 + build: ^2.4.2 + source_gen: ^2.0.0 + glob: ^2.1.2 + path: + shelf: + dart_style: + change_case: + http: ^1.3.0 + json: ^0.20.4 + sse: ^4.1.7 + +dev_dependencies: + lints: ^5.0.0 + test: ^1.24.0 diff --git a/packages/globe_tasks/pubspec.lock b/packages/globe_tasks/pubspec.lock index c4d51885..ff14bc24 100644 --- a/packages/globe_tasks/pubspec.lock +++ b/packages/globe_tasks/pubspec.lock @@ -5,23 +5,23 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: "45cfa8471b89fb6643fe9bf51bd7931a76b8f5ec2d65de4fb176dba8d4f22c77" + sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab" url: "https://pub.dev" source: hosted - version: "73.0.0" + version: "76.0.0" _macros: dependency: transitive description: dart source: sdk - version: "0.3.2" + version: "0.3.3" analyzer: dependency: transitive description: name: analyzer - sha256: "4959fec185fe70cce007c57e9ab6983101dbe593d2bf8bbfb4453aaec0cf470a" + sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e" url: "https://pub.dev" source: hosted - version: "6.8.0" + version: "6.11.0" args: dependency: "direct main" description: @@ -178,10 +178,10 @@ packages: dependency: transitive description: name: macros - sha256: "0acaed5d6b7eab89f63350bccd82119e6c602df0f391260d0e32b5e23db79536" + sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656" url: "https://pub.dev" source: hosted - version: "0.1.2-main.4" + version: "0.1.3-main.0" matcher: dependency: transitive description: diff --git a/packages/shelf_rpc/.gitignore b/packages/shelf_rpc/.gitignore new file mode 100644 index 00000000..3cceda55 --- /dev/null +++ b/packages/shelf_rpc/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/shelf_rpc/CHANGELOG.md b/packages/shelf_rpc/CHANGELOG.md new file mode 100644 index 00000000..effe43c8 --- /dev/null +++ b/packages/shelf_rpc/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/shelf_rpc/README.md b/packages/shelf_rpc/README.md new file mode 100644 index 00000000..a04e69d0 --- /dev/null +++ b/packages/shelf_rpc/README.md @@ -0,0 +1,272 @@ +# Shelf RPC + +A type-safe server-client RPC (Remote Procedure Call) library for Dart, built on-top of [Shelf](https://pub.dev/packages/shelf). + +Shelf RPC handles the complexity of building a server side APIs in Dart, without having to worry about the complexities such as routing, middleware, serialization etc, and also provides a client to call your procedures from your client (e.g. Flutter app) with full type safety. + +## Features + +- 🚀 **Type-safe**: Full end-to-end type safety between client and server, with custom class support +- 🛠️ **Easy Integration**: Seamlessly integrates with existing Shelf applications and middleware +- 🔌 **Dependency Injection**: Built-in DI integration with Shelf +- 📡 **Server-Sent Events**: Built-in support for real-time streaming using SSE + +## How it works + +Create a file within your project which defines your RPC procedures: + +```dart +// lib/rpc.dart +import 'package:shelf_rpc/shelf_rpc.dart'; + +final r = ShelfRpc(); + +@RpcEndpoint() +final api = r.router({ + /// Say hello to the given name + #sayHello: r.procedure().exec((String name) => 'Hello, $name!'), +}); +``` + +Using build_runner, this will generate two files: + +- `rpc.client.dart`: An RPC client to call your procedures from your client (e.g. Flutter app). See [RPC Client](#rpc-client) for more details. +- `rpc.server.dart`: A file containing Shelf [Pipeline](https://pub.dev/documentation/shelf/latest/shelf/Pipeline-class.html)s to hook up to your Shelf server. See [RPC Server](#rpc-server) for more details. + +Within your client, you can call your procedures like so: + +```dart +final client = RpcClient.api(Uri.parse('http://localhost:8080')); +final result = await client.sayHello('John'); +print(result); // Hello, John! +``` + +## Getting Started + +### Installation + +Firstly install the `shelf_rpc` package, and additionally `shelf` and `build_runner` if you haven't already: + +```sh +dart pub add shelf_rpc shelf +dart pub add dev:build_runner +``` + +### Define RPC endpoints + +You can define one or more RPC endpoints in a file, and use the `@RpcEndpoint()` annotation to mark the file as an RPC endpoint. This must annotate a public top-level variable which returns a `RpcRouter`. Create a file in your project, e.g. `rpc.dart`, and define your endpoints like so: + +```dart +final r = ShelfRpc(); + +@RpcEndpoint() +final api = r.router({ + // ... +}); +``` + +### Define RPC Procedures + +Once you have defined an entrypoint with a router, you can define procedures on that router (or nest routers). + +A procedure is a function which accepts parameters, and returns a value. What happens within this function is up to you, but the key part is that the functionality is server-side only, meaning you can communicate with databases, other services etc. + +Shelf RPC automatically handles the serialization and deserialization of parameters and return values, so you can pass in and out many Dart types, including custom classes. Below is an example of some procedures to give you an idea of how they work. + +```dart +import 'models.dart' show User; + +User getUser(String name) => User(name: name); + +@RpcEndpoint() +final api = r.router({ + // Tear off the function reference to getUser. + #getUser: r.procedure().exec(getUser), + + // Procedure which returns a DatTime object (not serializable by default, but handled by Shelf RPC) + #getTime: r.procedure().exec(() => DateTime.now()), + + // Procedure which returns a Future> + #getUsers: r.procedure().exec(() async { + // Fetch users from a database (e.g. Postgres) + final users = await queryDatabase('SELECT * FROM users'); + return users.map((e) => User.fromJson(e)).toList(); + }), + + // Procedure which streams the results back to the client + #listUsers: r.procedure().exec(() async* { + // Stream results from a database query + final stream = queryDatabaseAsStream('SELECT * FROM users'); + + // As the stream sends events, yield them to the client + await for (final row in stream) { + yield User.fromJson(row); + } + }), + + // Procedure which accepts complex parameters + #createUser: r.procedure().exec((User user, {String? address}) async { + await insertUser(user.toJson(), address); + }), + + // A nested router (can be many levels deep) + #users: r.router({ + #get: r.procedure().exec((String id) => [User(name: id)]), + }), +}); +``` + +### Generate RPC Client and Server + +To generate the RPC client and server, run `dart pub run build_runner build` within your project. + +Two files will be generated next to your file containing the entrypoints, with `.client.dart` and `.server.dart` suffixes. + +## RPC Client + +The `.client.dart` file contains a `RpcClient` class which you can use to call your RPC endpoints from your client. Import this file in your client code, for example a Flutter application. The class exposes static methods for each endpoint defined in your server file, for example: + +```dart +// lib/client.dart +import 'rpc.client.dart'; + +void main() async { + // Create a client instance pointing to your server + final client = RpcClient.api(Uri.parse('http://localhost:8080')); + + await client.sayHello('John'); // Hello, John! + await client.getUser('123'); // User(name: 'John') + await client.getUsers(); // List + await client.listUsers(); // Stream + await client.createUser(User(name: 'John'), address: '123 Main St'); // null + await client.users.get('123'); // [User(name: 'John')] +} +``` + +### RPC Server + +The `.server.dart` file contains a `Pipeline` for each endpoint defined in your file. Shelf RPC is designed to be plugged into your existing Shelf application, rather than being a standalone server. You can easily mount a generated `Pipeline` into your existing Shelf application like so: + +```dart +// lib/server.dart +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as shelf_io; + +// Import the generated server file +import 'rpc.server.dart' as rpc; + +void main() async { + // Mount the generated pipeline into your existing Shelf application at the root. + // Alternatively, you can mount the pipeline at a specific path, e.g. '/api'. + var server = await shelf_io.serve(rpc.api, 'localhost', 8080); + print('Serving at http://${server.address.host}:${server.port}'); +} +``` + +## Middleware + +Shelf RPC is designed to be used with Shelf middleware. You can easily add middleware to your RPC endpoints by using the `use` method on the `ShelfRpc` instance or a procedure. This allows you to build composable middleware for your RPC endpoints. + +### Global Middleware + +You can add global middleware to your RPC endpoints by using the `use` method on the `ShelfRpc` instance. This middleware will be applied to all RPC procedures using the instance. + +```dart +import 'package:shelf_rpc/shelf_rpc.dart'; +import 'package:shelf_cors_headers/shelf_cors_headers.dart'; + +final r = ShelfRpc().use(corsHeaders()); + +@RpcEndpoint() +final api = r.router({ + // ... +}); +``` + +### Procedure Middleware + +You can also add middleware to a specific procedure by using the `use` method on the procedure. This middleware will only be applied to the procedure it is called on. This allows you to compose middleware for specific procedures, for example you may want to have "authentcated" procedures, or "admin" procedures, for example: + +```dart +import 'package:shelf_rpc/shelf_rpc.dart'; + +final r = ShelfRpc(); +// Attach some middleware to the procedure which checks for some auth token (e.g. JWT in the header). +final authenticated = r.procedure().use(jwtAuth()); + +// Attach some middleware to the procedure which checks if the JWT token from the authenticated procedure is for an admin user. +final admin = authenticated.use(isAdmin()); + +@RpcEndpoint() +final api = r.router({ + // Only authenticated users can access this endpoint + #getPosts: authenticated.exec(...), + // Only admin users can access this endpoint + #deletePost: admin.exec(...), +}); +``` + +## Server vs Client code + +Any code defined within your RPC file is server-side only (an `io` environment). This means that you cannot define client-side code within your RPC file, and you cannot define server-side code within your client file. + +It is however common to define types which are shared between the client and server, such as `User` or `Post` classes. Shelf RPC imports these types from their source file in both generated the server and client code, therefore your shared types should be defined in a separate file which has no reference to client or server code. + +A typical filename for shared types is `models.dart`. + +## Serialization + +Shelf RPC supports the following types out of the box: + +- `String` +- `int` +- `double` +- `bool` +- `List` +- `Map` +- `Iterable` +- `Stream` +- `DateTime` +- `Duration` +- `RegExp` +- `Uri` +- `UriData` +- `Uint8List` +- `BigInt` + +Note that `dynamic` and `Object` are not supported, you must provide concrete types. + +### Custom Types + +When providing custom types, such as a `User` class, you must enure that the class is serializable, by ensuring that the class has a `toJson` method and a `fromJson` factory constructor, e.g. + +```dart +class User { + final String name; + + User({required this.name}); + + Map toJson() => {'name': name}; + + factory User.fromJson(Map json) { + return User( + name: json['name'] as String, + ); + } +} +``` + +Alternatively, you can use 3rd party packages such as [json_serializable](https://pub.dev/packages/json_serializable) to automatically generate the `toJson` and `fromJson` methods for your class. + +## Rules of RPC + +There's a number of rules to follow whilst using Shelf RPC: + +1. Entrypoints must be public top-level variables. +2. Router keys (Symbols) must not start with an `_` and can only contain alphanumeric characters. +3. Duplicate router keys are not allowed and will be skipped, +4. All entrypoints must return a `RpcRouter` instance. +5. Any types which are not listed above must be serializable, see [Serialization](#serialization) for more details. + +TODO: Explain that functions, records, param futures, nested async types, etc are not supported. + diff --git a/packages/shelf_rpc/analysis_options.yaml b/packages/shelf_rpc/analysis_options.yaml new file mode 100644 index 00000000..ea2c9e94 --- /dev/null +++ b/packages/shelf_rpc/analysis_options.yaml @@ -0,0 +1 @@ +include: package:lints/recommended.yaml \ No newline at end of file diff --git a/packages/shelf_rpc/build.yaml b/packages/shelf_rpc/build.yaml new file mode 100644 index 00000000..6027408d --- /dev/null +++ b/packages/shelf_rpc/build.yaml @@ -0,0 +1,8 @@ +builders: + shelf_rpc_builder: + import: "package:shelf_rpc/builders.dart" + builder_factories: ["shelfRpcBuilder"] + build_extensions: + { ".dart": [".server.dart", ".client.dart"] } + auto_apply: root_package + build_to: source diff --git a/packages/shelf_rpc/example/shelf_rpc_example.dart b/packages/shelf_rpc/example/shelf_rpc_example.dart new file mode 100644 index 00000000..fed302f7 --- /dev/null +++ b/packages/shelf_rpc/example/shelf_rpc_example.dart @@ -0,0 +1,8 @@ +import 'package:shelf_rpc/shelf_rpc.dart'; + +final r = ShelfRpc(); + +@RpcEntrypoint() +final example = r.router({ + #sayHello: r.procedure().exec((String name) => 'Hello, $name!'), +}); diff --git a/packages/shelf_rpc/lib/builders.dart b/packages/shelf_rpc/lib/builders.dart new file mode 100644 index 00000000..14475634 --- /dev/null +++ b/packages/shelf_rpc/lib/builders.dart @@ -0,0 +1,6 @@ +import 'package:build/build.dart'; +import 'package:shelf_rpc/src/build/shelf_rpc_builder.dart'; + +Builder shelfRpcBuilder([BuilderOptions? options]) { + return ShelfRpcBuilder(options); +} diff --git a/packages/shelf_rpc/lib/client.dart b/packages/shelf_rpc/lib/client.dart new file mode 100644 index 00000000..6d59f846 --- /dev/null +++ b/packages/shelf_rpc/lib/client.dart @@ -0,0 +1,3 @@ +library; + +export 'src/client/rpc_http_client.dart'; diff --git a/packages/shelf_rpc/lib/shelf_rpc.dart b/packages/shelf_rpc/lib/shelf_rpc.dart new file mode 100644 index 00000000..64843a70 --- /dev/null +++ b/packages/shelf_rpc/lib/shelf_rpc.dart @@ -0,0 +1,5 @@ +library; + +export 'src/shelf_rpc.dart'; +export 'src/shelf_utils.dart'; +export 'src/provider.dart'; diff --git a/packages/shelf_rpc/lib/src/build/shelf_rpc_builder.dart b/packages/shelf_rpc/lib/src/build/shelf_rpc_builder.dart new file mode 100644 index 00000000..2e2e8044 --- /dev/null +++ b/packages/shelf_rpc/lib/src/build/shelf_rpc_builder.dart @@ -0,0 +1,314 @@ +import 'package:shelf_rpc/shelf_rpc.dart'; +import 'package:shelf_rpc/src/build/sourced_entrypoint.dart'; +import 'package:shelf_rpc/src/build/sourced_imports.dart'; +import 'package:shelf_rpc/src/build/sourced_rpc_procedure.dart'; +import 'package:shelf_rpc/src/build/sourced_rpc_procedure_parameter.dart'; +import 'package:shelf_rpc/src/build/supported_type.dart'; +import 'package:shelf_rpc/src/build/writers/client_writer.dart'; +import 'package:shelf_rpc/src/build/writers/server_writer.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:build/build.dart'; +import 'package:source_gen/source_gen.dart'; + +/// Type checker for the [RpcRouter] type. +const _rpcRouter = TypeChecker.fromRuntime(RpcRouter); + +/// Type checker for the [ExecutedRpcProcedure] type. +const _executedRpcProcedure = TypeChecker.fromRuntime(ExecutedRpcProcedure); + +/// Any reserved names which are exposed in the client to prevent conflicts. +const _reservedNames = ['postRequest', 'getRequest', 'sseRequest']; + +/// Builder for the [ShelfRpc] library. +final class ShelfRpcBuilder extends Builder { + /// The options for the builder, if any. + final BuilderOptions? options; + + /// Creates a new [ShelfRpcBuilder] instance. + ShelfRpcBuilder(this.options); + + @override + Future build(BuildStep buildStep) async { + final library = await buildStep.resolver.libraryFor(buildStep.inputId); + final reader = LibraryReader(library); + + // Identify all registered entrypoints in the library. + final registered = reader.annotatedWith( + TypeChecker.fromRuntime(RpcEntrypoint), + ); + + // If no entrypoints are registered, return early. + if (registered.isEmpty) { + return; + } + + // An instance to keep track of the imports. + final imports = SourcedImports(); + + // A list of all the entrypoints. + final entrypoints = []; + + // Cycle through all the registered entrypoints. + for (final annotated in registered) { + // Check if the annotated element is a top-level variable. + if (annotated.element is! TopLevelVariableElement) { + throw InvalidGenerationSourceError( + 'The @RpcEntrypoint annotation can only be applied to top-level functions.', + element: annotated.element, + ); + } + + final element = annotated.element as TopLevelVariableElement; + + // Check if the annotated element is private (e.g. _foo). + if (element.isPrivate) { + throw InvalidGenerationSourceError( + '@RpcEntrypoint annotation must be used on a public top-level variable.', + element: element, + ); + } + + // Check if the annotated element is a [RpcRouter] instance. + if (!_rpcRouter.isExactlyType(element.type)) { + throw InvalidGenerationSourceError( + '@RpcEntrypoint must annotate a [RpcRouter] instance.', + element: element, + ); + } + + // Get the declaration of the annotated element, with resolution enabled. + // TODO: Should we async await this for loop to speed up the build? + final declaration = await buildStep.resolver.astNodeFor( + element, + resolve: true, + ); + + // Check if the declaration is a variable declaration. + if (declaration == null || declaration is! VariableDeclaration) { + throw InvalidGenerationSourceError( + 'Unable to find a Router instance (expected a variable declaration)', + element: element, + ); + } + + // Get the initializer of the declaration. + final initializer = declaration.initializer; + + // Check if the initializer is a method invocation. + if (initializer is! MethodInvocation) { + throw InvalidGenerationSourceError( + 'Unable to find a Router instance (expected a method invocation)', + element: element, + ); + } + + // Routes can only be initialized with a Map as the single argument. + final routes = + initializer.argumentList.arguments.single as SetOrMapLiteral; + + // A list of all the procedures. + final procedures = []; + + // A generic function to process the routes. + void processRoutes( + List elements, [ + List? prefix = const [], + ]) { + // Iterate over the routes in the router. + for (final (element as MapLiteralEntry) in elements) { + // Get the key of the route (the symbol name). + final key = (element.key as SymbolLiteral).name; + + if (_reservedNames.contains(key)) { + throw InvalidGenerationSourceError( + 'The reserved name "$key" cannot be used as an RPC route identifier.', + ); + } + + // Skip private routes and duplicate routes. + if (key.startsWith('_') || + !RegExp(r'^[a-zA-Z][a-zA-Z0-9]*$').hasMatch(key)) { + print('Skipping invalid RPC Route ${prefix == null ? key : [ + ...prefix, + key + ].join('.')}'); + continue; + } + + // Get the value of the route (router or procedure). + final value = element.value as MethodInvocation; + + // Get the chunks of the id, with any previous prefix. + final idChunks = prefix == null ? [key] : [...prefix, key]; + + // Join the chunks to form the id to make the RPC identifier. + final id = idChunks.join('.'); + + // Skip duplicate procedures. + if (procedures.any((p) => p.id == id)) { + continue; + } + + // Switch on the static type of the value. These extend a sealed + // [RpcRouterDefinition] type. + switch (value.staticType!) { + // If the Map value is a [RpcRouter], process the nested routes. + case final type when _rpcRouter.isExactlyType(type): + // Get the nested routes from the router. + final nestedRoutes = + value.argumentList.arguments.single as SetOrMapLiteral; + + // Process the nested routes. + return processRoutes(nestedRoutes.elements, idChunks); + + // If the Map value is an [ExecutedRpcProcedure]. + case final type when _executedRpcProcedure.isExactlyType(type): + { + // Get the function from the executed procedure. + // TODO: This may need to be cleverer to support named args such as method override. + final expression = value.argumentList.arguments.single; + + // The reference to the function. + // Since there's a number of different types of expressions + // that can be used to reference a function, we need to check + // each one and extract the function type and parameters. + DartType? function; + + // The parameters of the function. + List parameters = []; + + if (expression is SimpleIdentifier) { + final element = expression.staticElement; + if (element is FunctionElement) { + function = element.type.returnType; + parameters = element.parameters; + } + } else if (expression is ConstructorReference) { + final element = expression.constructorName.staticElement; + if (element is ConstructorElement) { + function = element.returnType; + parameters = element.parameters; + } + } else if (expression is PropertyAccess) { + final element = expression.propertyName.staticElement; + if (element is MethodElement) { + function = element.returnType; + parameters = element.parameters; + } + } else if (expression is FunctionExpression) { + final type = expression.staticType; + if (type is FunctionType) { + function = type.returnType; + parameters = type.parameters; + } + } else if (expression is PrefixedIdentifier) { + // Handle static method references like: + // #staticMethodTearOff: r.procedure().exec(Bar.foo), + final element = expression.staticElement; + if (element is MethodElement) { + function = element.returnType; + parameters = element.parameters; + } + } + + // Shouldn't technically get here, but we might have missed + // a way to reference a function. + if (function == null) { + throw InvalidGenerationSourceError( + 'Unable to determine return procedure: ${expression.runtimeType}. Please create an issue.', + ); + } + + // Get the supported interface type from the function. + final interface = SupportedType.fromDartType( + imports: imports, + type: function, + library: library, + )..assertValid(); + + // TODO validate the return type of the interface is not async + + // For each function parameter, get the supported interface type. + final paramInterfaces = parameters.map((parameter) { + final interface = SupportedType.fromDartType( + imports: imports, + type: parameter.type, + library: library, + ); + + // We can't support async parameters. + if (interface is AsyncType) { + throw InvalidGenerationSourceError( + 'Parameters cannot be async.', + element: parameter, + ); + } + + // Assert that the interface is valid. + interface.assertValid(); + + // Return the parameter with the interface type. + return SourcedRpcProcedureParameter(interface, parameter); + }).toList(); + + // Add the procedure to the list. + procedures.add( + SourcedRpcProcedure( + id: id, + interface: interface, + parameters: paramInterfaces, + ), + ); + } + case _: + // Shouldn't get here. + throw Exception('Invalid procedure type.'); + } + } + } + + // Start the process of iterating over the base routes. + processRoutes(routes.elements); + + // Add the entrypoint to the list. + entrypoints.add( + SourcedEntrypoint( + importId: imports.register(element.source!.uri), + name: element.name, + procedures: procedures, + ), + ); + } + + // Create the server and client code. + final server = ServerWriter(imports: imports, entrypoints: entrypoints); + final client = ClientWriter(imports: imports, entrypoints: entrypoints); + final asset = AssetId(buildStep.inputId.package, buildStep.inputId.path); + + // Write the server and client code to the build step. + await Future.wait([ + buildStep.writeAsString( + asset.changeExtension('.server.dart'), + server.write(), + ), + buildStep.writeAsString( + asset.changeExtension('.client.dart'), + client.write(), + ), + ]); + } + + @override + Map> get buildExtensions => { + r'.dart': ['.server.dart', '.client.dart'], + }; +} + +extension on SymbolLiteral { + /// Returns the name of the symbol without the leading `#` character. + String get name { + return toString().substring(1); + } +} diff --git a/packages/shelf_rpc/lib/src/build/sourced_entrypoint.dart b/packages/shelf_rpc/lib/src/build/sourced_entrypoint.dart new file mode 100644 index 00000000..03561074 --- /dev/null +++ b/packages/shelf_rpc/lib/src/build/sourced_entrypoint.dart @@ -0,0 +1,20 @@ +import 'package:shelf_rpc/src/build/sourced_rpc_procedure.dart'; + +/// A class representing a single entrypoint in the generated code. +class SourcedEntrypoint { + /// Creates a new [SourcedEntrypoint] instance. + SourcedEntrypoint({ + required this.importId, + required this.name, + required this.procedures, + }); + + /// The import ID of the entry. + final int importId; + + /// The name of the entrypoint/top-level variable. + final String name; + + /// The procedures of the entry. + final List procedures; +} diff --git a/packages/shelf_rpc/lib/src/build/sourced_imports.dart b/packages/shelf_rpc/lib/src/build/sourced_imports.dart new file mode 100644 index 00000000..674cfc61 --- /dev/null +++ b/packages/shelf_rpc/lib/src/build/sourced_imports.dart @@ -0,0 +1,19 @@ +/// A class for tracking the imports used in the generated code. +final class SourcedImports { + /// The set of imports. + final imports = {}; + + /// Registers a URI and returns its index in the imports set. + /// If the URI is already registered, returns its existing index. + int register(Uri uri) { + final existingIndex = imports.lookup(uri); + if (existingIndex != null) { + return imports.toList().indexOf(uri); + } + imports.add(uri); + return imports.length - 1; + } + + /// Gets all registered imports + Set call() => Set.unmodifiable(imports); +} diff --git a/packages/shelf_rpc/lib/src/build/sourced_rpc_procedure.dart b/packages/shelf_rpc/lib/src/build/sourced_rpc_procedure.dart new file mode 100644 index 00000000..9b387686 --- /dev/null +++ b/packages/shelf_rpc/lib/src/build/sourced_rpc_procedure.dart @@ -0,0 +1,50 @@ +import 'package:shelf_rpc/src/build/supported_type.dart'; +import 'package:shelf_rpc/src/build/sourced_rpc_procedure_parameter.dart'; + +/// A class representing a sourced RPC procedure from the user's code. +final class SourcedRpcProcedure { + /// The id of the procedure (derived from the symbol / parents). + final String id; + + /// The interface of the procedure. + final SupportedType interface; + + /// The parameters of the procedure. + final Iterable parameters; + + /// Creates a new [SourcedRpcProcedure] instance. + SourcedRpcProcedure({ + required this.id, + required this.interface, + required this.parameters, + }); + + /// The reconstructed typedef of the procedure. + String get typedef { + final b = StringBuffer(); + + final positional = parameters.where((p) => p.isPositional && !p.isOptional); + final optional = parameters.where((p) => p.isPositional && p.isOptional); + final named = parameters.where((p) => p.isNamed); + + b.writeln('${interface.typeDefinition} Function('); + + b.write(positional.map((p) => p.typedef).join(', ')); + + if (optional.isNotEmpty) { + if (positional.isNotEmpty) b.write(', '); + b.write('[${optional.map((p) => p.typedef).join(', ')}]'); + } + + if (named.isNotEmpty) { + if (positional.isNotEmpty || optional.isNotEmpty) b.write(', '); + b.write( + '{${named.map((p) => '${p.isRequired ? 'required ' : ''}${p.typedef}').join(', ')}}', + ); + } + + b.writeln(')'); + + return b.toString(); + } +} diff --git a/packages/shelf_rpc/lib/src/build/sourced_rpc_procedure_parameter.dart b/packages/shelf_rpc/lib/src/build/sourced_rpc_procedure_parameter.dart new file mode 100644 index 00000000..f48346d7 --- /dev/null +++ b/packages/shelf_rpc/lib/src/build/sourced_rpc_procedure_parameter.dart @@ -0,0 +1,39 @@ +import 'package:analyzer/dart/element/element.dart'; +import 'package:shelf_rpc/src/build/supported_type.dart'; + +/// A class representing a sourced RPC procedure parameter from the user's code. +class SourcedRpcProcedureParameter { + /// The interface of the parameter. + final SupportedType interface; + + /// The parameter element (from Analyzer). + final ParameterElement parameter; + + /// Creates a new [SourcedRpcProcedureParameter] instance. + SourcedRpcProcedureParameter(this.interface, this.parameter); + + /// The name of the parameter. + String get name => parameter.name; + + /// The default value of the parameter (if any). + String? get defaultValue => parameter.defaultValueCode; + + /// Whether the parameter is named. + bool get isNamed => parameter.isNamed; + + /// Whether the parameter is positional. + bool get isPositional => parameter.isPositional; + + /// Whether the parameter is optional. + bool get isOptional => + parameter.isOptionalNamed || parameter.isOptionalPositional; + + /// Whether the parameter is required. + bool get isRequired => + parameter.isRequiredNamed || parameter.isRequiredPositional; + + /// The reconstructed typedef of the parameter. + String get typedef { + return '${interface.typeDefinition}${isOptional ? '?' : ''} $name'; + } +} diff --git a/packages/shelf_rpc/lib/src/build/supported_type.dart b/packages/shelf_rpc/lib/src/build/supported_type.dart new file mode 100644 index 00000000..ead11665 --- /dev/null +++ b/packages/shelf_rpc/lib/src/build/supported_type.dart @@ -0,0 +1,368 @@ +import 'package:shelf/shelf.dart' as shelf show Request; +import 'package:analyzer/dart/element/element.dart'; +import 'package:analyzer/dart/element/nullability_suffix.dart'; +import 'package:analyzer/dart/element/type.dart'; +import 'package:source_gen/source_gen.dart'; +import 'package:shelf_rpc/src/build/sourced_imports.dart'; +import 'package:shelf_rpc/src/serializers.dart'; + +/// A sealed class representing a [DartType] that can be used in the RPC. +sealed class SupportedType { + /// The type checker for the [shelf.Request] type. + static const _shelfRequestType = TypeChecker.fromRuntime(shelf.Request); + + /// Creates a new [SupportedType] instance from a [DartType]. + factory SupportedType.fromDartType({ + required DartType type, + required SourcedImports imports, + required LibraryElement library, + }) { + // Narrow down the type to an [InterfaceType] and throw errors for unsupported types. + final interface = switch (type) { + InterfaceType type => type, + DynamicType _ => throw InvalidGenerationSourceError( + 'Cannot return dynamic types.', + element: type.element, + ), + NeverType _ => throw InvalidGenerationSourceError( + 'Cannot return never types.', + element: type.element, + ), + TypeParameterType _ => throw InvalidGenerationSourceError( + 'Cannot return generic type parameters directly. Use a concrete type instead.', + element: type.element, + ), + _ => throw InvalidGenerationSourceError( + 'Unhandled type: $type ${type.runtimeType}', + element: type.element, + ), + }; + + // Narrow down the type to a supported type. + final supported = switch (interface) { + InterfaceType type when type.isDartAsyncFuture => FutureType._( + type, + imports: imports, + library: library, + ), + InterfaceType type when type.isDartAsyncFutureOr => FutureOrType._( + type, + imports: imports, + library: library, + ), + InterfaceType type when type.isDartAsyncStream => StreamType._( + type, + imports: imports, + library: library, + ), + InterfaceType type when type.isDartCoreNull => NullType._( + type, + imports: imports, + library: library, + ), + InterfaceType type when type.isDartCoreList => ListType._( + type, + imports: imports, + library: library, + ), + InterfaceType type when type.isDartCoreSet => SetType._( + type, + imports: imports, + library: library, + ), + InterfaceType type when type.isDartCoreMap => MapType._( + type, + imports: imports, + library: library, + ), + InterfaceType type when type.isDartCoreIterable => IterableType._( + type, + imports: imports, + library: library, + ), + // Handle the core types. + InterfaceType type + when type.isDartCoreBool || + type.isDartCoreDouble || + type.isDartCoreInt || + type.isDartCoreString || + type.isDartCoreNum => + CoreType(type, imports: imports, library: library), + // Throw on any unsupported types. + InterfaceType type + when type.isDartCoreEnum || + type.isDartCoreSymbol || + type.isDartCoreObject || + type.isDartCoreRecord || + type.isDartCoreType || + type.isDartCoreFunction => + throw InvalidGenerationSourceError( + 'RPC does not support type $type', + element: interface.element, + ), + InterfaceType _ => null, + }; + + // If the type is not supported, apply further checks. + if (supported == null) { + // If the type is a RequestContext, return the RequestContextType. + if (_shelfRequestType.isExactlyType(interface)) { + return ShelfRequestType( + interface, + imports: imports, + library: library, + ); + } + + // If the type has a serializer, we can use the CoreType, e.g. DateTime. + if (Serializers.instance.getFromType(interface) != null) { + return CoreType(interface, imports: imports, library: library); + } + + // Otherwise, its unknown (a user defined type or something else). + return UnknownType(interface, imports: imports, library: library); + } + + return supported; + } + + /// The [InterfaceType] that was used to create the [SupportedType]. + final InterfaceType type; + + /// The [SourcedImports] instance. + final SourcedImports imports; + + /// The [LibraryElement] that was used to create the [SupportedType]. + final LibraryElement library; + + /// Creates a new [SupportedType] instance. + SupportedType(this.type, {required this.imports, required this.library}); + + /// The name of the type. + String get name => type.getDisplayString(withNullability: false); + + /// Whether the type is nullable. + bool get isNullable => type.nullabilitySuffix == NullabilitySuffix.question; + + /// The type definition of the type. + /// This also should include any prefixed imports. + String get typeDefinition; + + /// An interface to allow subclasses to assert the type is valid. + void assertValid() => {}; +} + +/// A [SupportedType] that is unknown (a user defined type or something else). +final class UnknownType extends MultipleTypeArguments { + /// Creates a new [UnknownType] instance. + UnknownType(super.type, {required super.imports, required super.library}) + : importId = imports.register(type.element.source.uri); + + /// The import ID of the type. + final int importId; + + @override + void assertValid() { + // We can't support unknown types with type arguments, + // otherwise things just start getting complex. + if (typeArguments.isNotEmpty) { + throw InvalidGenerationSourceError( + 'Unknown type cannot have type arguments', + element: type.element, + ); + } + + assertSerializable(); + } + + /// Asserts that the type is serializable. + void assertSerializable() { + // TOOD: Improve this - should have no args. + final toJson = type.lookUpMethod2('toJson', library); + // TOOD: Improve this - should have 1 Map arg. + final fromJson = type.lookUpConstructor('fromJson', library); + + if (toJson == null || fromJson == null) { + throw InvalidGenerationSourceError( + 'Type is not serializable (missing or invalid toJson or fromJson method)', + element: type.element, + ); + } + } + + @override + String get typeDefinition { + return 'i$importId.$name${isNullable ? '?' : ''}'; + } +} + +/// Represents an async type with a single type argument. +/// For example: [Future], [Stream], etc. +final class AsyncType extends SingleTypeArgument { + AsyncType(super.type, {required super.imports, required super.library}); +} + +/// Represents a type with a single type argument. +/// For example: [List], [Set], etc. +final class SingleTypeArgument extends SupportedType { + /// Creates a new [SingleTypeArgument] instance. + SingleTypeArgument(super.type, + {required super.imports, required super.library}); + + /// The [SupportedType] argument of the type. + SupportedType get typeArgument => SupportedType.fromDartType( + type: type.typeArguments.first, + imports: imports, + library: library, + ); + + @override + void assertValid() { + typeArgument.assertValid(); + } + + @override + String get typeDefinition => '$name${isNullable ? '?' : ''}'; +} + +/// Represents a type with multiple type arguments. +/// For example: [Map], etc. +final class MultipleTypeArguments extends SupportedType { + /// Creates a new [MultipleTypeArguments] instance. + MultipleTypeArguments(super.type, + {required super.imports, required super.library}); + + /// The [SupportedType] arguments of the type. + Iterable get typeArguments => type.typeArguments.map( + (type) => SupportedType.fromDartType( + type: type, + imports: imports, + library: library, + ), + ); + + @override + String get typeDefinition => + typeArguments.map((t) => t.typeDefinition).join(', '); +} + +/// Represents a [Future] type with a single type argument. +/// For example: [Future], etc. +final class FutureType extends AsyncType { + FutureType._(super.type, {required super.imports, required super.library}); + + @override + String get typeDefinition => + 'Future${isNullable ? '?' : ''}<${typeArgument.typeDefinition}>'; +} + +/// Represents a [FutureOr] type with a single type argument. +/// For example: [FutureOr], etc. +final class FutureOrType extends AsyncType { + FutureOrType._(super.type, {required super.imports, required super.library}); + + @override + String get typeDefinition => + 'FutureOr${isNullable ? '?' : ''}<${typeArgument.typeDefinition}>'; +} + +/// Represents a [Stream] type with a single type argument. +/// For example: [Stream], etc. +final class StreamType extends AsyncType { + StreamType._(super.type, {required super.imports, required super.library}); + + @override + String get typeDefinition => + 'Stream${isNullable ? '?' : ''}<${typeArgument.typeDefinition}>'; +} + +/// Represents a [Map] type with a string key and a serializable value. +/// For example: [Map], etc. +final class MapType extends MultipleTypeArguments { + MapType._(super.type, {required super.imports, required super.library}); + + @override + void assertValid() { + final first = typeArguments.first; + final last = typeArguments.last; + + // For a [Map] to be serializable, the key must be a string. + if (first is! CoreType && !first.type.isDartCoreString) { + throw InvalidGenerationSourceError( + 'Map type must have a string key and a serializable value.', + element: type.element, + ); + } + + // For a [Map] to be serializable, the value must be serializable. + return switch (last) { + AsyncType() => throw InvalidGenerationSourceError( + 'Map type cannot have an async type argument', + element: type.element, + ), + ShelfRequestType() => null, + SupportedType type => type.assertValid(), + }; + } + + @override + String get typeDefinition => + 'Map${isNullable ? '?' : ''}<${typeArguments.first.typeDefinition}, ${typeArguments.last.typeDefinition}>'; +} + +/// Represents a [Null] type. +/// For example: [Null], etc. +final class NullType extends SupportedType { + NullType._(super.type, {required super.imports, required super.library}); + + @override + String get typeDefinition => 'Null'; +} + +/// Represents a [List] type with a single type argument. +/// For example: [List], etc. +final class ListType extends SingleTypeArgument { + ListType._(super.type, {required super.imports, required super.library}); + + @override + String get typeDefinition => + 'List${isNullable ? '?' : ''}<${typeArgument.typeDefinition}>'; +} + +/// Represents a [Set] type with a single type argument. +/// For example: [Set], etc. +final class SetType extends SingleTypeArgument { + SetType._(super.type, {required super.imports, required super.library}); + + @override + String get typeDefinition => + 'Set${isNullable ? '?' : ''}<${typeArgument.typeDefinition}>'; +} + +/// Represents an [Iterable] type with a single type argument. +/// For example: [Iterable], etc. +final class IterableType extends SingleTypeArgument { + IterableType._(super.type, {required super.imports, required super.library}); + + @override + String get typeDefinition => + 'Iterable${isNullable ? '?' : ''}<${typeArgument.typeDefinition}>'; +} + +/// Represents a core type. +/// For example: [String], [int], [bool], etc. +final class CoreType extends SupportedType { + CoreType(super.type, {required super.imports, required super.library}); + + @override + String get typeDefinition => '$name${isNullable ? '?' : ''}'; +} + +/// Represents a [ShelfRequestType] type. +final class ShelfRequestType extends CoreType { + ShelfRequestType(super.type, + {required super.imports, required super.library}); + + @override + String get typeDefinition => 'Request'; +} diff --git a/packages/shelf_rpc/lib/src/build/writers/base_writer.dart b/packages/shelf_rpc/lib/src/build/writers/base_writer.dart new file mode 100644 index 00000000..8a0efaaa --- /dev/null +++ b/packages/shelf_rpc/lib/src/build/writers/base_writer.dart @@ -0,0 +1,43 @@ +import 'package:dart_style/dart_style.dart'; +import 'package:shelf_rpc/src/build/sourced_entrypoint.dart'; +import 'package:shelf_rpc/src/build/sourced_imports.dart'; + +const header = ''' +// GENERATED CODE - DO NOT MODIFY BY HAND +// This file was generated by shelf_rpc. +// ignore_for_file: non_constant_identifier_names, unused_local_variable, implementation_imports, no_leading_underscores_for_local_identifiers, prefer_null_aware_operators + +'''; + +/// A base class for writing the generated code. +abstract class BaseWriter { + /// Creates a new [BaseWriter] instance. + BaseWriter({required this.imports, required this.entrypoints}); + + /// The imports of the generated code. + final SourcedImports imports; + + /// The entrypoints of the generated code. + final List entrypoints; + + /// The formatter for the generated code. + final formatter = DartFormatter( + languageVersion: DartFormatter.latestLanguageVersion, + ); + + /// The buffer for the generated code. + final StringBuffer b = StringBuffer(header.trimLeft()); + + /// Writes the generated code. + String write(); + + /// Writes the imports of the generated code with keyed aliases. + String writeImports() { + final b = StringBuffer(); + for (var i = 0; i < imports.imports.length; i++) { + final uri = imports.imports.elementAt(i); + b.writeln("import '$uri' as i$i;"); + } + return b.toString(); + } +} diff --git a/packages/shelf_rpc/lib/src/build/writers/client_writer.dart b/packages/shelf_rpc/lib/src/build/writers/client_writer.dart new file mode 100644 index 00000000..5b559415 --- /dev/null +++ b/packages/shelf_rpc/lib/src/build/writers/client_writer.dart @@ -0,0 +1,357 @@ +import 'package:change_case/change_case.dart'; +import 'package:shelf_rpc/src/build/writers/base_writer.dart'; +import 'package:shelf_rpc/src/build/sourced_entrypoint.dart'; +import 'package:shelf_rpc/src/build/sourced_rpc_procedure.dart'; +import 'package:shelf_rpc/src/build/supported_type.dart'; + +/// The writer for the client RPC code. +class ClientWriter extends BaseWriter { + /// Creates a new [ClientWriter] instance. + ClientWriter({required super.imports, required super.entrypoints}); + + @override + String write() { + b.writeln("import 'dart:convert';"); + b.writeln("import 'package:http/http.dart' as http;"); + b.writeln("import 'package:globe_functions/src/spec/serializer.dart';"); + b.writeln( + "import 'package:globe_functions/src/client/rpc_http_client.dart';", + ); + + // Add imports with unique import indexes + b.write(writeImports()); + b.writeln(); + + // Write the RpcClient class with named constructors for each entrypoint + b.writeln("class RpcClient extends RpcHttpClient {"); + b.writeln(); + + // Private constructor + b.writeln(" RpcClient._({required super.uri, super.client});"); + + // Static factory methods for each entrypoint + for (final entrypoint in entrypoints) { + final entrypointClassName = _generateClientClassName( + entrypoint, + '', + isEntrypoint: true, + ); + b.writeln(); + b.writeln(''' + static $entrypointClassName ${entrypoint.name}({ + required Uri uri, + http.Client? client, + }) => $entrypointClassName(RpcClient._(uri: uri, client: client)); +'''); + } + + b.writeln("}"); + b.writeln(); + + // First write the base class for all generated clients + b.writeln(''' +abstract base class _RpcGeneratedClient { + final RpcClient client; + const _RpcGeneratedClient(this.client); +} +'''); + + for (final entrypoint in entrypoints) { + final routes = _groupProceduresByPath(entrypoint.procedures); + final allPaths = _getAllPaths(routes.keys); + + // Generate internal client classes for all paths + for (final path in allPaths.where((k) => k.isNotEmpty)) { + b.writeln(); + _writeNestedClientClass(entrypoint, path, routes[path] ?? [], allPaths); + } + + // Generate the public entrypoint class + final entrypointClassName = _generateClientClassName( + entrypoint, + '', + isEntrypoint: true, + ); + b.writeln( + "final class $entrypointClassName extends _RpcGeneratedClient {", + ); + b.writeln(); + b.writeln(" const $entrypointClassName(super.client);"); + + // Generate getters for nested routes + _writeNestedRouteGetters(entrypoint, routes); + b.writeln(); + + // Write root level methods + if (routes[''] != null) { + for (final procedure in routes['']!) { + _writeProcedureMethod(procedure); + b.writeln(); + } + } + + b.writeln("}"); + } + + return formatter.format(b.toString()); + } + + Map> _groupProceduresByPath( + List procedures, + ) { + final routes = >{}; + for (final procedure in procedures) { + final segments = procedure.id.split('.'); + final path = segments.length == 1 + ? '' + : segments.take(segments.length - 1).join('.'); + routes[path] ??= []; + routes[path]!.add(procedure); + } + return routes; + } + + Set _getAllPaths(Iterable routes) { + final allPaths = {}; + + for (final route in routes) { + if (route.isEmpty) continue; + + final segments = route.split('.'); + var currentPath = ''; + + // Add all intermediate paths + for (var i = 0; i < segments.length; i++) { + currentPath = i == 0 ? segments[0] : '$currentPath.${segments[i]}'; + allPaths.add(currentPath); + } + } + + return allPaths; + } + + String _generateClientClassName( + SourcedEntrypoint entrypoint, + String path, { + bool isEntrypoint = false, + }) { + final parts = [ + 'Rpc', + entrypoint.className, + ...path.split('.').map((s) => s.toPascalCase()), + if (isEntrypoint) 'ClientEntrypoint' else 'Client', + ]; + + return parts.join(); + } + + void _writeNestedClientClass( + SourcedEntrypoint entrypoint, + String path, + List procedures, + Set allPaths, + ) { + final className = _generateClientClassName(entrypoint, path); + b.writeln('final class $className extends _RpcGeneratedClient {'); + b.writeln(' const $className(super.client);'); + + // Write methods for this path level + for (final procedure in procedures) { + _writeProcedureMethod(procedure); + } + + // Add getters for next level paths + final currentSegments = path.split('.'); + final nextLevelPaths = allPaths + .where((p) => p.startsWith('$path.')) + .where((p) => p.split('.').length == currentSegments.length + 1) + .map((p) => p.split('.').last) + .toSet(); + + for (final nextPath in nextLevelPaths) { + final nextClassName = _generateClientClassName( + entrypoint, + '$path.$nextPath', + ); + b.writeln( + ' $nextClassName get ${nextPath.toCamelCase()} => $nextClassName(client);', + ); + } + + b.writeln('}'); + } + + void _writeNestedRouteGetters( + SourcedEntrypoint entrypoint, + Map> routes, + ) { + final topLevelPaths = routes.keys + .where((k) => k.isNotEmpty) + .map((k) => k.split('.')[0]) + .toSet(); + + for (final path in topLevelPaths) { + final className = _generateClientClassName(entrypoint, path); + b.writeln( + '\n $className get ${path.toCamelCase()} => $className(client);', + ); + } + } + + void _writeProcedureMethod(SourcedRpcProcedure procedure) { + final returnType = switch (procedure.interface) { + AsyncType() => procedure.interface.typeDefinition, + _ => 'Future<${procedure.interface.typeDefinition}>', + }; + + final methodName = procedure.id.split('.').last.toCamelCase(); + + // Write method signature with properly structured parameters + b.write(' $returnType $methodName('); + + // Write positional parameters first + final positional = procedure.parameters.where((p) => !p.isNamed); + b.write(positional.map((p) => p.typedef).join(', ')); + + // If we have named parameters, add them in curly braces + final named = procedure.parameters.where((p) => p.isNamed); + if (named.isNotEmpty) { + if (positional.isNotEmpty) b.write(', '); + b.write('{'); + b.write( + named + .map((p) => '${p.isOptional ? '' : 'required '}${p.typedef}') + .join(', '), + ); + b.write('}'); + } + + b.writeln(') async {'); + + // Build the request body + b.writeln('final positional = [];'); + b.writeln('final named = {};'); + + // Handle positional arguments + for (final param in procedure.parameters.where((p) => !p.isNamed)) { + b.write('positional.add('); + b.write(_serializeType(param.interface, param.name)); + b.writeln(');'); + } + + // Handle named arguments + for (final param in procedure.parameters.where((p) => p.isNamed)) { + b.write('named["${param.name}"] = '); + if (param.isOptional) { + b.write('${param.name} == null ? null : '); + } + b.write(_serializeType(param.interface, param.name)); + b.writeln(';'); + } + + // Construct the request body + b.writeln(); + b.writeln('final body = jsonEncode({'); + b.writeln(' "id": "${procedure.id}",'); + b.writeln(' "params": {'); + b.writeln(' "positional": positional,'); + b.writeln(' "named": named,'); + b.writeln(' },'); + b.writeln('});'); + b.writeln(); + + if (procedure.interface is StreamType) { + // TODO: handle streaming + throw UnimplementedError('Streaming is not supported yet'); + } else { + // POST request + b.write('final result = await client.postRequest("${procedure.id}",'); + // b.write('final result = await client.getRequest("${procedure.id}",'); + b.writeln(' namedParams: named,'); + b.writeln(' positionalParams: positional,'); + b.writeln(');'); + b.writeln(); + + // Return the deserialized result + b.writeln('return ${_deserializeType(procedure.interface, 'result')};'); + } + + b.writeln(' }'); + } + + String _serializeType( + SupportedType type, + String value, { + bool isRoot = true, + }) { + String generateNullCheck(String value, String code) { + return type.isNullable ? '$value == null ? null : $code' : code; + } + + if (type is NullType) { + return 'null'; + } else if (type is UnknownType) { + return generateNullCheck(value, '$value.toJson()'); + } else if (type is ListType || type is SetType || type is IterableType) { + final innerType = (type as SingleTypeArgument).typeArgument; + final mapCode = generateNullCheck( + value, + '$value.map((item) => ${_serializeType(innerType, "item", isRoot: false)})', + ); + return isRoot ? '$mapCode.toList()' : mapCode; + } else if (type is MapType) { + final valueType = type.typeArguments.last; + return generateNullCheck( + value, + '$value.map((key, value) => MapEntry(key, ${_serializeType(valueType, "value", isRoot: false)}))', + ); + } else { + return generateNullCheck( + value, + 'Serializers.instance.get<${type.name}>().serialize($value)', + ); + } + } + + String _deserializeType( + SupportedType type, + String value, { + bool isRoot = true, + }) { + String generateNullCheck(String value, String code) { + return type.isNullable ? '$value == null ? null : $code' : code; + } + + if (type is NullType) { + return 'null'; + } else if (type is UnknownType) { + return generateNullCheck( + value, + 'i${type.importId}.${type.name}.fromJson($value)', + ); + } else if (type is ListType || type is SetType || type is IterableType) { + final innerType = (type as SingleTypeArgument).typeArgument; + final mapCode = generateNullCheck( + value, + '($value as List).map((item) => ${_deserializeType(innerType, "item", isRoot: false)})', + ); + return isRoot ? '$mapCode.toList()' : mapCode; + } else if (type is MapType) { + final valueType = type.typeArguments.last; + return generateNullCheck( + value, + '($value as Map).map((key, value) => MapEntry(key, ${_deserializeType(valueType, "value", isRoot: false)}))', + ); + } else { + return generateNullCheck( + value, + 'Serializers.instance.get<${type.name}>().deserialize($value)', + ); + } + } +} + +extension on SourcedEntrypoint { + /// The class name of the entrypoint. + String get className => name.toPascalCase(); +} diff --git a/packages/shelf_rpc/lib/src/build/writers/server_writer.dart b/packages/shelf_rpc/lib/src/build/writers/server_writer.dart new file mode 100644 index 00000000..145ee632 --- /dev/null +++ b/packages/shelf_rpc/lib/src/build/writers/server_writer.dart @@ -0,0 +1,283 @@ +import 'package:shelf_rpc/src/build/writers/base_writer.dart'; +import 'package:shelf_rpc/src/build/sourced_rpc_procedure.dart'; +import 'package:shelf_rpc/src/build/supported_type.dart'; + +/// The writer for the server RPC code. +class ServerWriter extends BaseWriter { + /// Creates a new [ServerWriter] instance. + ServerWriter({required super.imports, required super.entrypoints}); + + @override + String write() { + b.writeln("import 'dart:convert';"); + b.writeln("import 'package:shelf/shelf.dart';"); + b.writeln("import 'package:shelf_rpc/shelf_rpc.dart';"); + b.writeln("import 'package:shelf_rpc/src/serializers.dart';"); + b.writeln( + "import 'package:globe_functions/src/server/request_params.dart';", + ); + b.writeln( + "import 'package:globe_functions/src/server/request_context.dart';", + ); + b.writeln("import 'package:globe_functions/src/server/sse_response.dart';"); + + // Add imports with unique import indexes + b.write(writeImports()); + + for (final entrypoint in entrypoints) { + b.writeln('final ${entrypoint.name} = Pipeline()'); + b.writeln(' .addHandler((Request request) async {'); + b.writeln(' final params = await RequestParams.fromRequest(request);'); + b.writeln(' final positional = params.positional;'); + b.writeln(' final named = params.named;'); + + // Import the entrypoint from the users library. + b.writeln( + ' final _${entrypoint.name} = i${entrypoint.importId}.${entrypoint.name};', + ); + + // Create a pipeline and add middleware for the entrypoint. + b.writeln(' Pipeline pipeline = Pipeline();'); + b.writeln(); + b.writeln(' for (final m in _${entrypoint.name}.middleware) {'); + b.writeln(' pipeline = pipeline.addMiddleware(m);'); + b.writeln(' }'); + + // Add switch statement for procedures. + b.writeln(); + b.writeln(' switch (params.id) {'); + + for (final procedure in entrypoint.procedures) { + b.writeln(' case "${procedure.id}":'); + + final parts = procedure.id.split('.'); + + // Get the procedure from the entrypoint - if its nested, get the nested procedure. + if (parts.length == 1) { + b.writeln( + 'final procedure = _${entrypoint.name}.routes[#${parts[0]}] as ExecutedProcedure;', + ); + b.writeln('final middleware = procedure.middleware;'); + } else { + var i = 0; + for (final part in parts) { + final isLast = i == parts.length - 1; + if (isLast) { + final prevVar = + i == 0 + ? '_${entrypoint.name}' + : '_${parts.take(i).join('_')}'; + b.writeln( + 'final procedure = $prevVar.routes[#$part] as ExecutedProcedure;', + ); + b.writeln('final middleware = procedure.middleware;'); + } else { + final prevVar = + i == 0 + ? '_${entrypoint.name}' + : '_${parts.take(i).join('_')}'; + final nextVar = '_${parts.take(i + 1).join('_')}'; + b.writeln('final $nextVar = $prevVar.routes[#$part] as Router;'); + } + i++; + } + } + + // Apply middleware to the pipeline. + b.writeln(); + b.writeln('for (final m in middleware) {'); + b.writeln(' pipeline = pipeline.addMiddleware(m);'); + b.writeln('}'); + + // Add handler to the pipeline. + b.writeln(); + b.writeln('return pipeline.addHandler((request) async {'); + b.writeln(_buildFunctionHandler(procedure)); + b.writeln('})(request);'); + } + + // Missing procedure case. + b.writeln(' default:'); + b.writeln(' return Response.notFound("Unknown procedure");'); + b.writeln(' }'); + + b.writeln(' });'); + } + + return formatter.format(b.toString()); + } + + String _buildFunctionHandler(SourcedRpcProcedure procedure) { + final b = StringBuffer(); + final positional = procedure.parameters.where((p) => p.isPositional); + final named = procedure.parameters.where((p) => p.isNamed); + + b.writeln('final fn = procedure.fn as ${procedure.typedef};'); + + // Build each positional parameter in order. + var p = 0; + for (final param in positional) { + b.write('final p$p = '); + + if (param.interface is ShelfRequestType) { + // Do nothing. + } else if (param.isOptional) { + b.write('positional[$p] == null ? null : '); + } else { + b.write('positional[$p] = '); + } + + if (param.interface is ShelfRequestType) { + b.write('request;'); + } else if (param.interface is UnknownType) { + b.write( + '${param.interface.typeDefinition}.fromJson(positional[$p] as Map);', + ); + } else { + b.write( + 'Serializers.instance.get<${param.interface.name}>().deserialize(positional[$p]);', + ); + } + p++; + } + + // Build each named parameter in order. + var n = 0; + for (final param in named) { + b.write('final n$n = '); + + if (param.interface is ShelfRequestType) { + // Do nothing. + } else if (param.isOptional) { + b.write('named["${param.name}"] == null ? null : '); + } else { + b.write('named["${param.name}"] = '); + } + + if (param.interface is ShelfRequestType) { + b.write('request;'); + } else if (param.interface is UnknownType) { + b.write( + '${param.interface.typeDefinition}.fromJson(named["${param.name}"]);', + ); + } else { + b.write( + 'Serializers.instance.get<${param.interface.name}>().deserialize(named["${param.name}"]);', + ); + } + n++; + } + + b.writeln(''); + b.write('final result = '); + + switch (procedure.interface) { + case FutureType(): + case FutureOrType(): + b.write('await fn('); + default: + b.write('fn('); + break; + } + + // Add positional parameters first. + for (var i = 0; i < positional.length; i++) { + final param = positional.elementAt(i); + b.writeln('p$i'); + if (param.defaultValue != null) { + b.write(' ?? ${param.defaultValue}'); + } + b.writeln(','); + } + + for (var i = 0; i < named.length; i++) { + final param = named.elementAt(i); + b.write('${param.name}: n$i'); + if (param.defaultValue != null) { + b.write(' ?? ${param.defaultValue}'); + } + b.writeln(','); + } + b.writeln(');'); + + final returnType = switch (procedure.interface) { + FutureType t => t.typeArgument, + FutureOrType t => t.typeArgument, + _ => procedure.interface, + }; + + // Helper function to generate serialization code for a type + String serializeType( + SupportedType type, + String value, { + bool isRoot = true, + }) { + String generateNullCheck(String value, String code) { + return type.isNullable ? '$value == null ? null : $code' : code; + } + + if (type is NullType) { + return 'null'; + } else if (type is UnknownType) { + return generateNullCheck(value, '$value.toJson()'); + } else if (type is ListType || type is SetType || type is IterableType) { + final innerType = (type as SingleTypeArgument).typeArgument; + final mapCode = generateNullCheck( + value, + '$value.map((item) => ${serializeType(innerType, "item", isRoot: false)})', + ); + return isRoot ? '$mapCode.toList()' : mapCode; + } else if (type is MapType) { + final valueType = type.typeArguments.last; + return generateNullCheck( + value, + '$value.map((key, value) => MapEntry(key, ${serializeType(valueType, "value", isRoot: false)}))', + ); + } else { + return generateNullCheck( + value, + 'Serializers.instance.get<${type.name}>().serialize($value)', + ); + } + } + + if (returnType is StreamType) { + final innerType = returnType.typeArgument; + + // If the return type is nullable, we still need to return a SSE + // response, so instead we just create an empty stream. + b.write('final stream = '); + if (returnType.isNullable) { + b.write('result ?? Stream<${innerType.typeDefinition}>.empty();'); + } else { + b.write('result;'); + } + + b.writeAll([ + 'final sse = SseResponse<${innerType.typeDefinition}>(', + ' request: request,', + ' stream: stream,', + // Since the return type is still a Stream, we need the "inner" type. + ' serializer: (data) => ${serializeType(innerType, 'data')},', + ');', + '', + 'return sse.response();', + ], '\n'); + return b.toString(); + } + + // Serialize the result. + b.write('final serialized = '); + + if (returnType.isNullable) { + b.write('result == null ? null : '); + } + + b.writeln('${serializeType(returnType, 'result')};'); + + // Return the result as a JSON response. + b.writeln('return Response.ok(jsonEncode({ "result": serialized }));'); + + return b.toString(); + } +} diff --git a/packages/shelf_rpc/lib/src/client/http_client.dart b/packages/shelf_rpc/lib/src/client/http_client.dart new file mode 100644 index 00000000..33dd4e90 --- /dev/null +++ b/packages/shelf_rpc/lib/src/client/http_client.dart @@ -0,0 +1,6 @@ +import 'package:http/http.dart' as http; + +/// Creates a new [http.Client] instance for the server. +http.Client createHttpClient() { + return http.Client(); +} diff --git a/packages/shelf_rpc/lib/src/client/http_client.web.dart b/packages/shelf_rpc/lib/src/client/http_client.web.dart new file mode 100644 index 00000000..effb0a10 --- /dev/null +++ b/packages/shelf_rpc/lib/src/client/http_client.web.dart @@ -0,0 +1,8 @@ +import 'package:http/browser_client.dart' as http; +import 'package:http/http.dart' as http; + +/// Creates a new [http.Client] instance for the web using +/// the browser client with credentials enabled. +http.Client createHttpClient() { + return http.BrowserClient()..withCredentials = true; +} diff --git a/packages/shelf_rpc/lib/src/client/rpc_http_client.dart b/packages/shelf_rpc/lib/src/client/rpc_http_client.dart new file mode 100644 index 00000000..b781c7dc --- /dev/null +++ b/packages/shelf_rpc/lib/src/client/rpc_http_client.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'http_client.dart' if (dart.library.html) 'http_client.web.dart' + show createHttpClient; + +/// A class which is used to make RPC calls to the server. +abstract class RpcHttpClient { + /// The HTTP client. + final http.Client _httpClient; + + /// The URI of the server. + final Uri _uri; + + /// Creates a new [RpcHttpClient] instance. + RpcHttpClient({required Uri uri, http.Client? client}) + : _uri = uri, + _httpClient = client ?? createHttpClient(); + + /// Handles the response from the server. + Future _handleResponse(http.Response response) async { + if (response.statusCode != 200) { + throw Exception("Failed to make RPC call: ${response.statusCode}"); + } + + return jsonDecode(response.body)["result"]; + } + + /// Makes a POST request to the server. + Future postRequest( + String id, { + required Map namedParams, + required List positionalParams, + }) async { + final body = jsonEncode({ + "id": id, + "params": {"positional": positionalParams, "named": namedParams}, + }); + + final response = await _httpClient.post( + _uri, + body: body, + headers: {"Content-Type": "application/json"}, + ); + + return _handleResponse(response); + } + + /// Makes a GET request to the server. + Future getRequest( + String id, { + required Map namedParams, + required List positionalParams, + }) async { + final uri = _uri.replace( + queryParameters: { + "id": id, + "named": base64Encode(utf8.encode(jsonEncode(namedParams))), + "positional": base64Encode(utf8.encode(jsonEncode(positionalParams))), + }, + ); + return _handleResponse(await _httpClient.get(uri)); + } +} diff --git a/packages/shelf_rpc/lib/src/provider.dart b/packages/shelf_rpc/lib/src/provider.dart new file mode 100644 index 00000000..1ed4961b --- /dev/null +++ b/packages/shelf_rpc/lib/src/provider.dart @@ -0,0 +1,23 @@ +import 'package:shelf/shelf.dart'; +import 'shelf_utils.dart'; + +/// Creates a middleware that provides a dependency of type [T] +Middleware provider(T Function(Request request) create) { + return (Handler innerHandler) { + return (Request request) { + final newRequest = request.set(create(request)); + return innerHandler(newRequest); + }; + }; +} + +/// Creates a middleware that provides an async dependency of type [T] +Middleware asyncProvider( + Future Function(Request request) create) { + return (Handler innerHandler) { + return (Request request) { + final newRequest = request.setAsync(create(request)); + return innerHandler(newRequest); + }; + }; +} diff --git a/packages/shelf_rpc/lib/src/serializers.dart b/packages/shelf_rpc/lib/src/serializers.dart new file mode 100644 index 00000000..f9de0668 --- /dev/null +++ b/packages/shelf_rpc/lib/src/serializers.dart @@ -0,0 +1,195 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:analyzer/dart/element/type.dart'; +import 'package:source_gen/source_gen.dart'; + +/// An interface for serializing and deserializing objects. +abstract class Serializer { + const Serializer(); + + /// Creates a new serializer with the given serialize and deserialize functions + static Serializer create({ + required Object? Function(T value) serialize, + required T Function(W value) deserialize, + }) => + _FunctionSerializer( + serializeFn: serialize, + deserializeFn: deserialize, + ); + + /// Serializes [value] to a JSON-compatible type + /// If T is nullable, value may be null + Object? serialize(T value); + + /// Deserializes [value] from a JSON-compatible type + /// If value is null and T is nullable, returns null + /// If value is null and T is non-nullable, throws an error + T deserialize(Object? value); + + /// Helper to verify wire type and throw clear errors + W assertWireType(Object? value) { + if (value == null) { + if (null is T) { + throw Exception('Cannot convert null to wire type $W for $runtimeType'); + } + throw Exception( + 'Cannot deserialize null to non-nullable type $T for $runtimeType', + ); + } + if (value is! W) { + throw Exception( + 'Expected wire type $W but got ${value.runtimeType} for $runtimeType', + ); + } + return value as W; + } +} + +class _FunctionSerializer + extends Serializer { + const _FunctionSerializer({ + required this.serializeFn, + required this.deserializeFn, + }); + + final Object? Function(T value) serializeFn; + final T Function(W value) deserializeFn; + + @override + Object? serialize(T value) => value == null ? null : serializeFn(value); + + @override + T deserialize(Object? value) { + if (value == null) { + if (null is T) { + return null as T; + } + throw Exception( + 'Cannot deserialize null to non-nullable type ${T} for $runtimeType', + ); + } + return deserializeFn(assertWireType(value)); + } +} + +class Serializers { + static final _instance = Serializers._(); + static Serializers get instance => _instance; + + static const bigInt = TypeChecker.fromRuntime(BigInt); + static const dateTime = TypeChecker.fromRuntime(DateTime); + static const duration = TypeChecker.fromRuntime(Duration); + static const regExp = TypeChecker.fromRuntime(RegExp); + static const uri = TypeChecker.fromRuntime(Uri); + static const uriData = TypeChecker.fromRuntime(UriData); + static const uint8List = TypeChecker.fromRuntime(Uint8List); + static const list = TypeChecker.fromRuntime(List); + static const map = TypeChecker.fromRuntime(Map); + + final _serializers = {}; + final _typeTokenSerializers = {}; + + Serializers._() { + // Register built-in serializers + register( + Serializer.create( + serialize: (v) => v, + deserialize: (v) => v, + ), + ); + register( + Serializer.create( + serialize: (v) => v, + deserialize: (v) => v.toInt(), + ), + ); + register( + Serializer.create( + serialize: (v) => v, + deserialize: (v) => v.toDouble(), + ), + ); + register( + Serializer.create(serialize: (v) => v, deserialize: (v) => v), + ); + register( + Serializer.create( + serialize: (v) => v.toString(), + deserialize: (v) => BigInt.parse(v), + ), + ); + register( + Serializer.create( + serialize: (v) => v.toIso8601String(), + deserialize: (v) => DateTime.parse(v), + ), + ); + register( + Serializer.create( + serialize: (v) => v.inMicroseconds.toString(), + deserialize: (v) => Duration(microseconds: int.parse(v)), + ), + ); + register( + Serializer.create( + serialize: (v) => v.pattern, + deserialize: (v) => RegExp(v), + ), + ); + register( + Serializer.create( + serialize: (v) => v.toString(), + deserialize: (v) => Uri.parse(v), + ), + ); + register( + Serializer.create( + serialize: (v) => v.toString(), + deserialize: (v) => UriData.parse(v), + ), + ); + register( + Serializer.create( + serialize: (v) => base64.encode(v), + deserialize: (v) => v.isEmpty ? Uint8List(0) : base64.decode(v), + ), + ); + } + + /// Registers a serializer for the given type. + void register(Serializer serializer) { + _serializers[T] = serializer; + // Also register with type token for generic types + _typeTokenSerializers['$T'] = serializer; + } + + /// Gets a serializer for the [DartType]. + Serializer? getFromType(DartType type) { + final serializer = switch (type) { + DartType _ when type.isDartCoreString => get(), + DartType _ when type.isDartCoreInt => get(), + DartType _ when type.isDartCoreDouble => get(), + DartType _ when type.isDartCoreBool => get(), + DartType _ when type.isDartCoreMap => get(), + DartType _ when type.isDartCoreList => get(), + DartType _ when type.isDartCoreSet => get(), + DartType _ when type.isDartAsyncFuture => null, + DartType _ when type.isDartAsyncStream => null, + DartType _ when type.isDartCoreIterable => null, + _ => _typeTokenSerializers[type.getDisplayString(withNullability: true)], + }; + return serializer as Serializer?; + } + + Serializer get() { + final serializer = _serializers[T] ?? _typeTokenSerializers['$T']; + if (serializer == null) { + throw Exception('No serializer registered for type $T'); + } + return serializer as Serializer; + } + + T deserialize(Object? value) => get().deserialize(value); + Object? serialize(T value) => get().serialize(value); +} diff --git a/packages/shelf_rpc/lib/src/server/request_params.dart b/packages/shelf_rpc/lib/src/server/request_params.dart new file mode 100644 index 00000000..18034e60 --- /dev/null +++ b/packages/shelf_rpc/lib/src/server/request_params.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:shelf/shelf.dart' show Request; + +/// A class which is used to parse the request parameters for an RPC request. +final class RequestParams { + /// Creates a new [RequestParams] instance from a [Request]. + static Future fromRequest(Request request) async { + switch (request.method) { + case 'GET': + final Map params = {}; + final queryParams = request.url.queryParameters; + params['id'] = queryParams['id']; + params['named'] = + jsonDecode(utf8.decode(base64Decode(queryParams['named'] ?? ''))) ?? + {}; + params['positional'] = jsonDecode( + utf8.decode(base64Decode(queryParams['positional'] ?? ''))) ?? + []; + return RequestParams._(params); + case 'POST': + final body = await request.readAsString(); + final json = jsonDecode(body) as Map; + return RequestParams._(json); + default: + throw Exception('Unsupported method: ${request.method}'); + } + } + + /// The parsed parameters. + final Map _params; + + /// Creates a new [RequestParams] instance from a map of parameters. + RequestParams._(this._params); + + /// The incoming RPC identifier. + String get id => _params['id'] as String; + + /// The named parameters of the procedure. + Map get named => _params['named'] ?? {}; + + /// The positional parameters of the procedure. + List get positional => _params['positional'] ?? []; +} diff --git a/packages/shelf_rpc/lib/src/server/sse_response.dart b/packages/shelf_rpc/lib/src/server/sse_response.dart new file mode 100644 index 00000000..1e1a6320 --- /dev/null +++ b/packages/shelf_rpc/lib/src/server/sse_response.dart @@ -0,0 +1,57 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:shelf/shelf.dart' as shelf; + +/// A class which is used to send an SSE response to the client. +class SseResponse { + /// Creates a new [SseResponse] instance. + SseResponse({ + required this.request, + required this.stream, + required this.serializer, + }); + + /// The subscription to the stream. + late StreamSubscription subscription; + + /// The Shelf request. + final shelf.Request request; + + /// The stream to send to the client. + final Stream stream; + + /// The serializer function for the stream events. + final Function(T event) serializer; + + shelf.Response response() { + // Create a new StreamController to manage the SSE events + final controller = StreamController(); + + subscription = stream.listen( + (data) { + controller.add('data: ${jsonEncode({"result": serializer(data)})}\n\n'); + }, + onDone: () async { + await controller.close(); + }, + onError: (error, stackTrace) async { + controller.add( + 'event: error\ndata: ${jsonEncode({"error": error.toString()})}\n\n', + ); + // TODO: Should we close the controller here? + }, + ); + + return shelf.Response.ok( + controller.stream.transform(utf8.encoder), + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', + 'Connection': 'keep-alive', + }, + ); + } +} diff --git a/packages/shelf_rpc/lib/src/shelf_rpc.dart b/packages/shelf_rpc/lib/src/shelf_rpc.dart new file mode 100644 index 00000000..ff01c7b0 --- /dev/null +++ b/packages/shelf_rpc/lib/src/shelf_rpc.dart @@ -0,0 +1,108 @@ +import 'package:shelf/shelf.dart' show Middleware; + +/// A class which is used to define the entrypoint for the RPC routes. +/// +/// Define this in files exposing one or more [ShelfRpc] instances. The build +/// pipeline will detect this and generate a client for each entrypoint and +/// a Shelf [Pipeline] to expose the RPC routes to the client. +final class RpcEntrypoint { + /// Creates a new [RpcEntrypoint] instance. + const RpcEntrypoint(); +} + +/// Defines the type of route which can be defined in the ShelfRpc instance. +/// +/// This can either be a [ExecutedRpcProcedure] or a (nested) [RpcRoutes] +sealed class RpcRouterDefinition {} + +/// A map of routes which can be used to define the RPC routes. +/// +/// The key is a [Symbol] which is used to define the RPC identifier. Identifiers +/// starting with a `_` are ignored. Duplicate identifiers are ignored. +/// +/// The [RpcRouteDefinition] allows for returning a [RpcProcedure] or a (nested) +/// [RpcRoutes]. +typedef RpcRoutes = Map; + +/// The Shelf RPC class for defining the RPC routes and middleware. +/// +/// ```dart +/// final rpc = ShelfRpc() +/// .use(someShelfMiddleware()) +/// +/// rpc.router({ +/// #hello: rpc.procedure().exec((req) => 'world'), +/// }); +/// ``` +final class ShelfRpc { + /// Keeps track of the middleware which is applied to all routes. + final List _middleware; + + /// Creates a new [ShelfRpc] instance. + const ShelfRpc([this._middleware = const []]); + + /// Applies middleware to all routes defined in the [ShelfRpc] instance. + ShelfRpc use(Middleware middleware) { + return ShelfRpc([..._middleware, middleware]); + } + + /// Creates a new [RpcRouter] instance with the given [RpcRoutes] and middleware. + /// + /// The [RpcRouter] is used to define the routes and middleware for the Shelf RPC + /// instance. + RpcRouter router(RpcRoutes routes) { + return RpcRouter._(routes, _middleware); + } + + /// Creates a new [RpcProcedure] with any middleware applied from the parent + /// [ShelfRpc] or [RpcProcedure]s. + RpcProcedure procedure() => RpcProcedure._(_middleware); +} + +/// Represents a router for the RPC routes. +final class RpcRouter implements RpcRouterDefinition { + /// The routes which are defined in the [RpcRouter] instance. + final RpcRoutes routes; + + /// The middleware which is applied to all routes defined in the [RpcRouter]. + final List middleware; + + /// Creates a new [RpcRouter] instance. + const RpcRouter._(this.routes, [this.middleware = const []]); +} + +/// Represents an executed RPC procedure, created when the user calls [RpcProcedure.exec]. +class ExecutedRpcProcedure implements RpcRouterDefinition { + /// The middleware which is applied to the [ExecutedRpcProcedure]. + final List middleware; + + /// The function which is executed when the [ExecutedRpcProcedure] is called. + final Function fn; + + /// Creates a new [ExecutedRpcProcedure] instance. + const ExecutedRpcProcedure( + this.middleware, { + required this.fn, + }); +} + +/// Represents a procedure for the RPC routes. +class RpcProcedure implements RpcRouterDefinition { + /// The middleware which is applied to the [RpcProcedure]. + final List middleware; + + /// Creates a new [RpcProcedure] instance. + const RpcProcedure._([this.middleware = const []]); + + /// Applies middleware to the [RpcProcedure]. + RpcProcedure use(Middleware middleware) { + return RpcProcedure._([...this.middleware, middleware]); + } + + /// Executes the [RpcProcedure] with the given function. + /// + /// The function is executed when the [RpcProcedure] is called. + ExecutedRpcProcedure exec(Function fn) { + return ExecutedRpcProcedure(middleware, fn: fn); + } +} diff --git a/packages/shelf_rpc/lib/src/shelf_utils.dart b/packages/shelf_rpc/lib/src/shelf_utils.dart new file mode 100644 index 00000000..81f99d69 --- /dev/null +++ b/packages/shelf_rpc/lib/src/shelf_utils.dart @@ -0,0 +1,86 @@ +import 'package:shelf/shelf.dart'; + +/// Extension on [Request] to provide dependency injection capabilities +extension RequestDependencyInjection on Request { + static const _dependencyKey = '_shelf_rpc_dependencies'; + + /// Internal method to get or create the dependency map + Map _getDependencyMap() { + final ctx = context; + final deps = ctx[_dependencyKey]; + if (deps is Map) { + return deps; + } + return {}; + } + + /// Sets a dependency of type [T] in the request context + Request set(T value) { + final deps = _getDependencyMap(); + return change(context: { + _dependencyKey: { + ...deps, + T: value, + }, + }); + } + + /// Sets an async dependency of type [T] in the request context + Request setAsync(Future value) { + final deps = _getDependencyMap(); + return change(context: { + ...context, + _dependencyKey: { + ...deps, + Future: value, + }, + }); + } + + /// Gets a dependency of type [T] from the request context + /// + /// Example: + /// ```dart + /// final userService = request.get(); + /// ``` + T get() { + final deps = _getDependencyMap(); + final value = deps[T]; + + if (value == null) { + throw StateError('No dependency found for type $T'); + } + + return value as T; + } + + /// Tries to get a dependency of type [T] from the request context + /// Returns null if not found + /// + /// Example: + /// ```dart + /// final userService = request.tryGet(); + /// ``` + T? tryGet() { + final deps = _getDependencyMap(); + final value = deps[T]; + return value as T?; + } + + /// Gets an async dependency of type [T] from the request context + /// + /// Example: + /// ```dart + /// final db = await request.getAsync(); + /// ``` + Future getAsync() async { + final deps = _getDependencyMap(); + final value = deps[Future]; + + if (value == null) { + throw StateError('No async dependency found for type $T'); + } + + return (value as Future); + } +} diff --git a/packages/shelf_rpc/pubspec.yaml b/packages/shelf_rpc/pubspec.yaml new file mode 100644 index 00000000..d5cc8716 --- /dev/null +++ b/packages/shelf_rpc/pubspec.yaml @@ -0,0 +1,22 @@ +name: shelf_rpc +description: Type-safe RPC Shelf Server and Client for Dart. +version: 0.0.1 +repository: https://github.com/invertase/globe + +environment: + sdk: ^3.7.0-83.0.dev + +dependencies: + analyzer: ^7.2.0 + build_runner: ^2.4.14 + build: ^2.4.2 + source_gen: ^2.0.0 + path: ^1.9.1 + shelf: ^1.4.2 + dart_style: ^3.0.1 + change_case: ^2.2.0 + http: ^1.3.0 + +dev_dependencies: + lints: ^5.0.0 + test: ^1.24.0 diff --git a/packages/shelf_rpc/test/shelf_rpc_test.dart b/packages/shelf_rpc/test/shelf_rpc_test.dart new file mode 100644 index 00000000..ab73b3a2 --- /dev/null +++ b/packages/shelf_rpc/test/shelf_rpc_test.dart @@ -0,0 +1 @@ +void main() {} diff --git a/templates/notes_app_shelf/frontend/pubspec.lock b/templates/notes_app_shelf/frontend/pubspec.lock index a6d18032..d57d6e42 100644 --- a/templates/notes_app_shelf/frontend/pubspec.lock +++ b/templates/notes_app_shelf/frontend/pubspec.lock @@ -13,50 +13,50 @@ packages: dependency: transitive description: name: async - sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 url: "https://pub.dev" source: hosted - version: "2.11.0" + version: "2.12.0" boolean_selector: dependency: transitive description: name: boolean_selector - sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" characters: dependency: transitive description: name: characters - sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.4.0" clock: dependency: transitive description: name: clock - sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.1.2" collection: dependency: transitive description: name: collection - sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.19.1" fake_async: dependency: transitive description: name: fake_async - sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.1" + version: "1.3.3" firebase_auth: dependency: "direct main" description: @@ -148,18 +148,18 @@ packages: dependency: transitive description: name: leak_tracker - sha256: "3f87a60e8c63aecc975dda1ceedbc8f24de75f09e4856ea27daf8958f2f0ce05" + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "10.0.5" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: name: leak_tracker_flutter_testing - sha256: "932549fb305594d82d7183ecd9fa93463e9914e1b67cacc34bc40906594a1806" + sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573 url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.9" leak_tracker_testing: dependency: transitive description: @@ -180,10 +180,10 @@ packages: dependency: transitive description: name: matcher - sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.16+1" + version: "0.12.17" material_color_utilities: dependency: transitive description: @@ -196,18 +196,18 @@ packages: dependency: transitive description: name: meta - sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.16.0" path: dependency: transitive description: name: path - sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" url: "https://pub.dev" source: hosted - version: "1.9.0" + version: "1.9.1" plugin_platform_interface: dependency: transitive description: @@ -220,55 +220,55 @@ packages: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_span: dependency: transitive description: name: source_span - sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.10.1" stack_trace: dependency: transitive description: name: stack_trace - sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.11.1" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.4.1" term_glyph: dependency: transitive description: name: term_glyph - sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "5b8a98dafc4d5c4c9c72d8b31ab2b23fc13422348d2997120294d3bac86b4ddb" + sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd url: "https://pub.dev" source: hosted - version: "0.7.2" + version: "0.7.4" typed_data: dependency: transitive description: @@ -289,10 +289,10 @@ packages: dependency: transitive description: name: vm_service - sha256: f652077d0bdf60abe4c1f6377448e8655008eef28f128bc023f7b5e8dfeb48fc + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "14.2.4" + version: "15.0.0" web: dependency: transitive description: @@ -302,5 +302,5 @@ packages: source: hosted version: "0.5.1" sdks: - dart: ">=3.3.0 <4.0.0" + dart: ">=3.7.0-0 <4.0.0" flutter: ">=3.18.0-18.0.pre.54"