├── packages ├── shelf_api │ ├── LICENSE │ ├── lib │ │ ├── shelf_api_client.dart │ │ ├── builder_utils.dart │ │ ├── src │ │ │ ├── util │ │ │ │ ├── map_extensions.dart │ │ │ │ └── stream_extensions.dart │ │ │ ├── api │ │ │ │ ├── content_types.dart │ │ │ │ ├── http_method.dart │ │ │ │ ├── handle_format_exceptions.dart │ │ │ │ ├── shelf_endpoint.dart │ │ │ │ └── t_response.dart │ │ │ ├── annotations │ │ │ │ ├── shelf_api.dart │ │ │ │ ├── query_param.dart │ │ │ │ ├── body_param.dart │ │ │ │ ├── path_param.dart │ │ │ │ ├── api_endpoint.dart │ │ │ │ └── api_method.dart │ │ │ ├── client │ │ │ │ └── t_response_body.dart │ │ │ └── riverpod │ │ │ │ ├── endpoint_ref.dart │ │ │ │ └── rivershelf.dart │ │ └── shelf_api.dart │ ├── .pubignore │ ├── analysis_options.yaml │ ├── dart_test.yaml │ ├── example │ │ ├── format_handler.dart │ │ ├── date_time_provider.dart │ │ ├── riverpod_request_handler.dart │ │ └── main.dart │ ├── test │ │ ├── unit │ │ │ ├── util │ │ │ │ ├── map_extensions_test.dart │ │ │ │ └── stream_extensions_test.dart │ │ │ ├── api │ │ │ │ ├── content_types_test.dart │ │ │ │ ├── http_method_test.dart │ │ │ │ └── handle_format_exceptions_test.dart │ │ │ ├── riverpod │ │ │ │ └── endpoint_ref_test.dart │ │ │ └── client │ │ │ │ └── t_response_body_test.dart │ │ └── integration │ │ │ ├── format_exception_handler_test.dart │ │ │ ├── test_helper.dart │ │ │ └── riverpod_test.dart │ ├── README.md │ ├── pubspec.yaml │ └── CHANGELOG.md └── shelf_api_builder │ ├── LICENSE │ ├── analysis_options.yaml │ ├── example │ ├── basic_enum.dart │ ├── endpoints │ │ ├── basic_endpoint.dart │ │ ├── middleware_endpoint.dart │ │ ├── routing_endpoint.dart │ │ ├── body_endpoint.dart │ │ ├── params_endpoint.dart │ │ └── response_endpoint.dart │ ├── basic_model.dart │ ├── example_api.dart │ └── main.dart │ ├── .pubignore │ ├── dart_test.yaml │ ├── lib │ ├── src │ │ ├── util │ │ │ ├── annotations.dart │ │ │ ├── constants.dart │ │ │ ├── extensions │ │ │ │ └── code_builder_extensions.dart │ │ │ ├── code │ │ │ │ ├── switch.dart │ │ │ │ ├── if.dart │ │ │ │ ├── try.dart │ │ │ │ └── literal_string_builder.dart │ │ │ └── type_checkers.dart │ │ ├── builders │ │ │ ├── base │ │ │ │ ├── spec_builder.dart │ │ │ │ ├── expression_builder.dart │ │ │ │ └── code_builder.dart │ │ │ ├── api │ │ │ │ ├── path_builder.dart │ │ │ │ ├── api_handler_builder.dart │ │ │ │ ├── response_builder.dart │ │ │ │ ├── query_builder.dart │ │ │ │ ├── api_implementation_builder.dart │ │ │ │ └── body_builder.dart │ │ │ ├── client │ │ │ │ ├── body_builder.dart │ │ │ │ ├── path_builder.dart │ │ │ │ ├── query_builder.dart │ │ │ │ ├── client_builder.dart │ │ │ │ ├── method_body_builder.dart │ │ │ │ ├── response_builder.dart │ │ │ │ └── method_builder.dart │ │ │ └── common │ │ │ │ └── from_json_builder.dart │ │ ├── readers │ │ │ ├── middleware_reader.dart │ │ │ ├── serializable_reader.dart │ │ │ ├── stringifiable_reader.dart │ │ │ ├── shelf_api_reader.dart │ │ │ ├── api_endpoint_reader.dart │ │ │ ├── path_param_reader.dart │ │ │ ├── api_method_reader.dart │ │ │ ├── query_param_reader.dart │ │ │ └── body_param_reader.dart │ │ ├── models │ │ │ ├── api_class.dart │ │ │ ├── endpoint.dart │ │ │ ├── endpoint_path_parameter.dart │ │ │ ├── serializable_type.dart │ │ │ ├── opaque_constant.dart │ │ │ ├── endpoint_method.dart │ │ │ ├── endpoint_query_parameter.dart │ │ │ ├── endpoint_body.dart │ │ │ ├── endpoint_response.dart │ │ │ └── opaque_type.dart │ │ ├── analyzers │ │ │ ├── api_class_analyzer.dart │ │ │ ├── methods_analyzer.dart │ │ │ ├── endpoint_analyzer.dart │ │ │ ├── query_analyzer.dart │ │ │ ├── path_analyzer.dart │ │ │ └── body_analyzer.dart │ │ ├── client_generator.dart │ │ └── endpoint_generator.dart │ └── shelf_api_builder.dart │ ├── build.yaml │ ├── test │ └── integration │ │ ├── middleware_endpoint_test.dart │ │ ├── test_helper.dart │ │ ├── basic_endpoint_test.dart │ │ └── routing_endpoint_test.dart │ ├── pubspec.yaml │ └── CHANGELOG.md ├── README.md ├── analysis_options.yaml ├── .vscode ├── settings.json └── tasks.json ├── pubspec.yaml ├── .github ├── workflows │ ├── auto_update.yaml │ ├── shelf_api_cd.yaml │ ├── shelf_api_builder_cd.yaml │ ├── shelf_api_builder_ci.yaml │ └── shelf_api_ci.yaml └── dependabot.yml ├── tool └── setup_git_hooks.dart ├── .gitignore ├── .devcontainer └── devcontainer.json └── LICENSE /packages/shelf_api/LICENSE: -------------------------------------------------------------------------------- 1 | ../../LICENSE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | packages/shelf_api_builder/README.md -------------------------------------------------------------------------------- /packages/shelf_api_builder/LICENSE: -------------------------------------------------------------------------------- 1 | ../../LICENSE -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | plugins: 2 | dart_test_tools: ^7.0.1 3 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/shelf_api_client.dart: -------------------------------------------------------------------------------- 1 | export 'src/client/t_response_body.dart'; 2 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:dart_test_tools/package.yaml 2 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/example/basic_enum.dart: -------------------------------------------------------------------------------- 1 | enum BasicEnum { value1, value2, value3 } 2 | -------------------------------------------------------------------------------- /packages/shelf_api/.pubignore: -------------------------------------------------------------------------------- 1 | # Unignore 2 | ## Generated build files 3 | !*.g.dart 4 | !*.mocks.dart 5 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/.pubignore: -------------------------------------------------------------------------------- 1 | # Unignore 2 | ## Generated build files 3 | !*.api.dart 4 | !*.client.dart 5 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/dart_test.yaml: -------------------------------------------------------------------------------- 1 | presets: 2 | integration: 3 | paths: 4 | - test/integration 5 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/builder_utils.dart: -------------------------------------------------------------------------------- 1 | export 'src/util/map_extensions.dart'; 2 | export 'src/util/stream_extensions.dart'; 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "refreshable", 4 | "rivershelf", 5 | "stringifiable" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /packages/shelf_api/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:dart_test_tools/package.yaml 2 | 3 | analyzer: 4 | exclude: 5 | - "**.mocks.dart" 6 | -------------------------------------------------------------------------------- /packages/shelf_api/dart_test.yaml: -------------------------------------------------------------------------------- 1 | presets: 2 | unit: 3 | paths: 4 | - test/unit 5 | 6 | integration: 7 | paths: 8 | - test/integration 9 | test_on: vm 10 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: shelf_api_root 2 | publish_to: none 3 | 4 | environment: 5 | sdk: ^3.10.0 6 | 7 | workspace: 8 | - packages/shelf_api 9 | - packages/shelf_api_builder 10 | -------------------------------------------------------------------------------- /packages/shelf_api/example/format_handler.dart: -------------------------------------------------------------------------------- 1 | import 'package:shelf/shelf.dart'; 2 | 3 | Future formatHandler(Request request) async => 4 | throw FormatException(await request.readAsString()); 5 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/util/annotations.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | @internal 5 | abstract base class Annotations { 6 | Annotations._(); 7 | 8 | static const Reference override = Reference('override'); 9 | } 10 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/src/util/map_extensions.dart: -------------------------------------------------------------------------------- 1 | /// Utility extensions used by the code generator 2 | extension ShelfApiMapX on Map { 3 | /// Maps the value of this map via [map] to [T] 4 | Map mapValue(TNew Function(T) map) => 5 | this.map((key, value) => MapEntry(key, map(value))); 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/auto_update.yaml: -------------------------------------------------------------------------------- 1 | name: Automatic dependency updates 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "22 23 * * 3" 7 | 8 | jobs: 9 | ci: 10 | name: Updates 11 | uses: Skycoder42/dart_test_tools/.github/workflows/auto-update.yml@main 12 | permissions: 13 | contents: write 14 | pull-requests: write 15 | secrets: 16 | githubToken: ${{ secrets.GH_PAT }} 17 | -------------------------------------------------------------------------------- /.github/workflows/shelf_api_cd.yaml: -------------------------------------------------------------------------------- 1 | name: CD - Publish shelf_api to pub.dev 2 | 3 | on: 4 | push: 5 | tags: 6 | - "shelf_api-v*" 7 | 8 | jobs: 9 | publish: 10 | name: Publish 11 | uses: Skycoder42/dart_test_tools/.github/workflows/publish.yml@main 12 | permissions: 13 | id-token: write 14 | with: 15 | tagPrefix: shelf_api-v 16 | workingDirectory: packages/shelf_api 17 | buildRunner: true 18 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/base/spec_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | @internal 5 | abstract base class SpecBuilder implements Spec { 6 | const SpecBuilder(); 7 | 8 | @protected 9 | T build(); 10 | 11 | @override 12 | R accept(SpecVisitor visitor, [R? context]) => 13 | build().accept(visitor, context); 14 | } 15 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/base/expression_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | @internal 5 | abstract base class ExpressionBuilder extends Expression { 6 | const ExpressionBuilder(); 7 | 8 | Expression build(); 9 | 10 | @override 11 | R accept(covariant ExpressionVisitor visitor, [R? context]) => 12 | build().accept(visitor, context); 13 | } 14 | -------------------------------------------------------------------------------- /packages/shelf_api/test/unit/util/map_extensions_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:shelf_api/src/util/map_extensions.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('ShelfApiMapX', () { 6 | test('mapValue maps value of map', () { 7 | const testMap = {'a': 1, 'b': 2}; 8 | 9 | final result = testMap.mapValue(expectAsync1((v) => v * 2, count: 2)); 10 | 11 | expect(result, {'a': 2, 'b': 4}); 12 | }); 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/shelf_api_builder_cd.yaml: -------------------------------------------------------------------------------- 1 | name: CD - Publish shelf_api_builder to pub.dev 2 | 3 | on: 4 | push: 5 | tags: 6 | - "shelf_api_builder-v*" 7 | 8 | jobs: 9 | publish: 10 | name: Publish 11 | uses: Skycoder42/dart_test_tools/.github/workflows/publish.yml@main 12 | permissions: 13 | id-token: write 14 | with: 15 | tagPrefix: shelf_api_builder-v 16 | workingDirectory: packages/shelf_api_builder 17 | buildRunner: true 18 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "devcontainers" 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | - package-ecosystem: "pub" 12 | directory: "/packages/shelf_api" 13 | schedule: 14 | interval: "weekly" 15 | - package-ecosystem: "pub" 16 | directory: "/packages/shelf_api_builder" 17 | schedule: 18 | interval: "weekly" 19 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/src/api/content_types.dart: -------------------------------------------------------------------------------- 1 | /// Constants for the default content types typically used by APIs 2 | abstract base class ContentTypes { 3 | ContentTypes._(); 4 | 5 | /// Content type for plain text. 6 | static const text = 'text/plain'; 7 | 8 | /// Content type for binary data. 9 | static const binary = 'application/octet-stream'; 10 | 11 | /// Content type for JSON. 12 | static const json = 'application/json'; 13 | 14 | /// All default content types. 15 | static const values = [text, binary, json]; 16 | } 17 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/base/code_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | // ignore: implementation_imports to extend private class 3 | import 'package:code_builder/src/specs/code.dart' show CodeVisitor; 4 | import 'package:meta/meta.dart'; 5 | 6 | @internal 7 | abstract base class CodeBuilder implements Code { 8 | const CodeBuilder(); 9 | 10 | Iterable build(); 11 | 12 | @override 13 | R accept(covariant CodeVisitor visitor, [R? context]) => 14 | Block.of(build()).accept(visitor, context); 15 | } 16 | -------------------------------------------------------------------------------- /packages/shelf_api/README.md: -------------------------------------------------------------------------------- 1 | # shelf_api 2 | [![CI/CD for shelf_api](https://github.com/Skycoder42/shelf_api/actions/workflows/shelf_api_ci.yaml/badge.svg)](https://github.com/Skycoder42/shelf_api/actions/workflows/shelf_api_ci.yaml) 3 | [![Pub Version](https://img.shields.io/pub/v/shelf_api)](https://pub.dev/packages/shelf_api) 4 | 5 | A package to declare rest API endpoints that generate into shelf handlers. 6 | 7 | This is the annotation and utility package for [shelf_api_builder](../shelf_api_builder). Please check out the README 8 | of that project for more details on how to use it. 9 | -------------------------------------------------------------------------------- /packages/shelf_api/example/date_time_provider.dart: -------------------------------------------------------------------------------- 1 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 2 | import 'package:shelf_api/shelf_api.dart'; 3 | 4 | part 'date_time_provider.g.dart'; 5 | 6 | @Riverpod(keepAlive: true) 7 | DateTime dateTimeSingleton(Ref ref) => DateTime.now(); 8 | 9 | @Riverpod() 10 | DateTime dateTimeFactory(Ref ref) => DateTime.now(); 11 | 12 | @Riverpod(keepAlive: true, dependencies: [shelfRequest]) 13 | DateTime requestDateTimeSingleton(Ref ref) => DateTime.now(); 14 | 15 | @Riverpod(dependencies: [shelfRequest]) 16 | DateTime requestDateTimeFactory(Ref ref) => DateTime.now(); 17 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/shelf_api.dart: -------------------------------------------------------------------------------- 1 | export 'src/annotations/api_endpoint.dart'; 2 | export 'src/annotations/api_method.dart'; 3 | export 'src/annotations/body_param.dart'; 4 | export 'src/annotations/path_param.dart'; 5 | export 'src/annotations/query_param.dart'; 6 | export 'src/annotations/shelf_api.dart'; 7 | 8 | export 'src/api/content_types.dart'; 9 | export 'src/api/handle_format_exceptions.dart'; 10 | export 'src/api/http_method.dart'; 11 | export 'src/api/shelf_endpoint.dart'; 12 | export 'src/api/t_response.dart'; 13 | 14 | export 'src/riverpod/endpoint_ref.dart'; 15 | export 'src/riverpod/rivershelf.dart'; 16 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/util/constants.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | import '../models/opaque_constant.dart'; 5 | 6 | @internal 7 | abstract base class Constants { 8 | Constants._(); 9 | 10 | static const utf8 = Reference('utf8', 'dart:convert'); 11 | 12 | static const json = Reference('json', 'dart:convert'); 13 | 14 | static Reference fromConstant(OpaqueConstant constant) => switch (constant) { 15 | final RevivedOpaqueConstant revived => Reference( 16 | revived.name, 17 | revived.source.toString(), 18 | ), 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/util/extensions/code_builder_extensions.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | @internal 5 | extension ExpressionX on Expression { 6 | Expression get yielded => 7 | CodeExpression(Block.of([const Code('yield '), code])); 8 | 9 | Expression get yieldedStar => 10 | CodeExpression(Block.of([const Code('yield* '), code])); 11 | 12 | // ignore: avoid_positional_boolean_parameters for obvious use case 13 | Expression autoProperty(String name, bool isNullable) => 14 | isNullable ? nullSafeProperty(name) : property(name); 15 | } 16 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/build.yaml: -------------------------------------------------------------------------------- 1 | builders: 2 | shelf_api_builder: 3 | import: "package:shelf_api_builder/shelf_api_builder.dart" 4 | builder_factories: 5 | - shelfApiBuilder 6 | - shelfApiClientBuilder 7 | build_extensions: 8 | ".dart": 9 | - ".api.dart" 10 | - ".client.dart" 11 | auto_apply: dependents 12 | build_to: source 13 | defaults: 14 | options: 15 | generateApi: true 16 | generateClient: true 17 | 18 | targets: 19 | $default: 20 | builders: 21 | shelf_api_builder: 22 | enabled: true 23 | generate_for: 24 | include: 25 | - example/** 26 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/readers/middleware_reader.dart: -------------------------------------------------------------------------------- 1 | import 'package:build/build.dart'; 2 | import 'package:meta/meta.dart'; 3 | import 'package:source_gen/source_gen.dart'; 4 | 5 | import '../models/opaque_constant.dart'; 6 | 7 | @internal 8 | mixin MiddlewareReader { 9 | ConstantReader get constantReader; 10 | 11 | bool get hasMiddleware => !constantReader.read('middleware').isNull; 12 | 13 | Future middleware(BuildStep buildStep) async { 14 | final middlewareReader = constantReader.read('middleware'); 15 | return middlewareReader.isNull 16 | ? null 17 | : await OpaqueConstant.revived(buildStep, middlewareReader.revive()); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/models/api_class.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | import 'endpoint.dart'; 4 | import 'opaque_constant.dart'; 5 | import 'opaque_type.dart'; 6 | 7 | @internal 8 | class ApiClass { 9 | final OpaqueType classType; 10 | final String className; 11 | final List endpoints; 12 | final String? basePath; 13 | final OpaqueConstant? middleware; 14 | 15 | ApiClass({ 16 | required this.classType, 17 | required this.className, 18 | required this.endpoints, 19 | required this.basePath, 20 | required this.middleware, 21 | }); 22 | 23 | String get implementationName => className.substring(1); 24 | 25 | String get clientName => '${implementationName}Client'; 26 | } 27 | -------------------------------------------------------------------------------- /tool/setup_git_hooks.dart: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env dart 2 | 3 | import 'dart:io'; 4 | 5 | Future main() async { 6 | final preCommitHook = File('.git/hooks/pre-commit'); 7 | await preCommitHook.parent.create(); 8 | await preCommitHook.writeAsString(''' 9 | #!/bin/bash 10 | set -eo pipefail 11 | 12 | pushd packages/shelf_api > /dev/null 13 | dart run dart_pre_commit 14 | popd > /dev/null 15 | 16 | pushd packages/shelf_api_builder > /dev/null 17 | dart run dart_pre_commit 18 | popd > /dev/null 19 | '''); 20 | 21 | if (!Platform.isWindows) { 22 | final result = await Process.run('chmod', ['a+x', preCommitHook.path]); 23 | stdout.write(result.stdout); 24 | stderr.write(result.stderr); 25 | exitCode = result.exitCode; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/models/endpoint.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | import 'endpoint_method.dart'; 4 | import 'opaque_constant.dart'; 5 | import 'opaque_type.dart'; 6 | 7 | @internal 8 | class Endpoint { 9 | final OpaqueType endpointType; 10 | final String name; 11 | final String? path; 12 | final List methods; 13 | final OpaqueConstant? middleware; 14 | 15 | Endpoint({ 16 | required this.endpointType, 17 | required this.name, 18 | required this.path, 19 | required this.methods, 20 | required this.middleware, 21 | }) { 22 | if (middleware != null && (path == null || path == '/')) { 23 | throw StateError('middleware cannot be set if path is "/" or null'); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/shelf_api/test/unit/api/content_types_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_test_tools/test.dart'; 2 | import 'package:shelf_api/src/api/content_types.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | void main() { 6 | group('ContentTypes', () { 7 | testData<(String, String)>( 8 | 'provides correct verbs', 9 | [ 10 | (ContentTypes.text, 'text/plain'), 11 | (ContentTypes.binary, 'application/octet-stream'), 12 | (ContentTypes.json, 'application/json'), 13 | ], 14 | (fixture) { 15 | expect(fixture.$1, fixture.$2); 16 | }, 17 | ); 18 | 19 | test('value report all verbs', () { 20 | expect(ContentTypes.values, [ 21 | 'text/plain', 22 | 'application/octet-stream', 23 | 'application/json', 24 | ]); 25 | }); 26 | }); 27 | } 28 | -------------------------------------------------------------------------------- /packages/shelf_api/test/integration/format_exception_handler_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:io'; 3 | 4 | import 'package:test/test.dart'; 5 | 6 | import 'test_helper.dart'; 7 | 8 | void main() { 9 | late ExampleServer server; 10 | 11 | setUpAll(() async { 12 | server = await ExampleServer.start(); 13 | }); 14 | 15 | tearDownAll(() async { 16 | await server.stop(); 17 | }); 18 | 19 | test('returns badRequest', () async { 20 | const testErrorMessage = 'this is a format error'; 21 | final response = await server.getRaw( 22 | Uri.parse('/format'), 23 | testErrorMessage, 24 | ); 25 | expect(response.statusCode, HttpStatus.badRequest); 26 | expect( 27 | response.transform(utf8.decoder).join(), 28 | completion(testErrorMessage), 29 | ); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/models/endpoint_path_parameter.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | import 'opaque_constant.dart'; 4 | import 'opaque_type.dart'; 5 | 6 | @internal 7 | class EndpointPathParameter { 8 | final String name; 9 | final OpaqueType type; 10 | final bool isString; 11 | final bool isEnum; 12 | final bool isDateTime; 13 | final OpaqueConstant? customParse; 14 | final OpaqueConstant? customToString; 15 | final bool urlEncode; 16 | 17 | EndpointPathParameter({ 18 | required this.name, 19 | required this.type, 20 | required this.isString, 21 | required this.isEnum, 22 | required this.isDateTime, 23 | required this.customParse, 24 | required this.customToString, 25 | required this.urlEncode, 26 | }); 27 | 28 | String get handlerParamName => '\$path\$$name'; 29 | } 30 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/example/endpoints/basic_endpoint.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:shelf_api/shelf_api.dart'; 4 | 5 | import '../basic_model.dart'; 6 | 7 | @ApiEndpoint('/basic') 8 | class BasicEndpoint extends ShelfEndpoint { 9 | BasicEndpoint(super.request); 10 | 11 | @Get('/') 12 | String get() => 'Hello, World!'; 13 | 14 | @Post(r'/complex/') 15 | Future> complexExample( 16 | int id, 17 | @BodyParam(fromJson: BasicModel.fromJsonX, toJson: BasicModel.toJsonX) 18 | BasicModel data, { 19 | required int factor, 20 | double delta = 0.5, 21 | String? extra, 22 | }) async => TResponse( 23 | HttpStatus.created, 24 | body: BasicModel((data.value * factor + delta).round()), 25 | headers: {HttpHeaders.locationHeader: '/examples/$id', 'X-Extra': ?extra}, 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/src/api/http_method.dart: -------------------------------------------------------------------------------- 1 | /// HTTP request method. 2 | abstract base class HttpMethod { 3 | HttpMethod._(); 4 | 5 | /// CONNECT 6 | static const connect = 'CONNECT'; 7 | 8 | /// DELETE 9 | static const delete = 'DELETE'; 10 | 11 | /// GET 12 | static const get = 'GET'; 13 | 14 | /// HEAD 15 | static const head = 'HEAD'; 16 | 17 | /// OPTIONS 18 | static const options = 'OPTIONS'; 19 | 20 | /// PATCH 21 | static const patch = 'PATCH'; 22 | 23 | /// POST 24 | static const post = 'POST'; 25 | 26 | /// PUT 27 | static const put = 'PUT'; 28 | 29 | /// TRACE 30 | static const trace = 'TRACE'; 31 | 32 | /// All known http methods. 33 | static const values = [ 34 | connect, 35 | delete, 36 | get, 37 | head, 38 | options, 39 | patch, 40 | post, 41 | put, 42 | trace, 43 | ]; 44 | } 45 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/test/integration/middleware_endpoint_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:test/test.dart'; 4 | 5 | import 'test_helper.dart'; 6 | 7 | void main() { 8 | late ExampleServer server; 9 | 10 | setUpAll(() async { 11 | server = await ExampleServer.start(); 12 | }); 13 | 14 | tearDownAll(() async { 15 | await server.stop(); 16 | }); 17 | 18 | test('/ endpoint applies global and endpoint middleware', () async { 19 | final response = await server.apiClient.middlewareGet(); 20 | expect(response.statusCode, HttpStatus.noContent); 21 | expect(response.headers, containsPair('X-Api', ['shelf_api'])); 22 | expect( 23 | response.headers, 24 | containsPair('X-Middleware', ['Api, Endpoint, Response']), 25 | ); 26 | expect(response.headers, containsPair('X-Extra', ['extra'])); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/models/serializable_type.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | import 'opaque_constant.dart'; 4 | import 'opaque_type.dart'; 5 | 6 | @internal 7 | enum Wrapped { none, list, map } 8 | 9 | @internal 10 | class SerializableType { 11 | final OpaqueType dartType; 12 | final Wrapped wrapped; 13 | final bool isNullable; 14 | final OpaqueType? jsonType; 15 | final OpaqueConstant? fromJson; 16 | final OpaqueConstant? toJson; 17 | 18 | SerializableType({ 19 | required this.dartType, 20 | required this.wrapped, 21 | required this.isNullable, 22 | required this.jsonType, 23 | this.fromJson, 24 | this.toJson, 25 | }) { 26 | if ((fromJson != null || toJson != null) && wrapped != Wrapped.none) { 27 | throw ArgumentError( 28 | 'If fromJson or toJson are set, wrapped must be Wrapped.none!', 29 | ); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/example/endpoints/middleware_endpoint.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:shelf/shelf.dart'; 4 | import 'package:shelf_api/shelf_api.dart'; 5 | 6 | @ApiEndpoint('/middleware', middleware: MiddlewareEndpoint.endpointMiddleware) 7 | class MiddlewareEndpoint extends ShelfEndpoint { 8 | MiddlewareEndpoint(super.request); 9 | 10 | @Get('/') 11 | Response get() => 12 | Response(HttpStatus.noContent, headers: {'X-Middleware': 'Response'}); 13 | 14 | static Middleware endpointMiddleware() => 15 | (next) => (request) async { 16 | final response = await next(request); 17 | return response.change( 18 | headers: { 19 | 'X-Middleware': [ 20 | 'Endpoint', 21 | ...?response.headersAll['X-Middleware'], 22 | ], 23 | 'X-Extra': 'extra', 24 | }, 25 | ); 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/models/opaque_constant.dart: -------------------------------------------------------------------------------- 1 | import 'package:build/build.dart'; 2 | import 'package:meta/meta.dart'; 3 | import 'package:source_gen/source_gen.dart'; 4 | 5 | import 'opaque_type.dart'; 6 | 7 | @internal 8 | sealed class OpaqueConstant { 9 | const OpaqueConstant(); 10 | 11 | static Future revived( 12 | BuildStep buildStep, 13 | Revivable revivable, 14 | ) async { 15 | final assetId = AssetId.resolve(revivable.source, from: buildStep.inputId); 16 | final element = await buildStep.resolver.libraryFor(assetId); 17 | return RevivedOpaqueConstant._( 18 | revivable.accessor, 19 | OpaqueType.uriForElement(buildStep, element)!, 20 | ); 21 | } 22 | } 23 | 24 | @internal 25 | class RevivedOpaqueConstant extends OpaqueConstant { 26 | final String name; 27 | final Uri source; 28 | 29 | RevivedOpaqueConstant._(this.name, this.source); 30 | } 31 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/models/endpoint_method.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | import 'endpoint_body.dart'; 4 | import 'endpoint_path_parameter.dart'; 5 | import 'endpoint_query_parameter.dart'; 6 | import 'endpoint_response.dart'; 7 | 8 | @internal 9 | class EndpointMethod { 10 | final String name; 11 | final String httpMethod; 12 | final String path; 13 | final EndpointResponse response; 14 | final EndpointBody? body; 15 | final List pathParameters; 16 | final List queryParameters; 17 | 18 | EndpointMethod({ 19 | required this.name, 20 | required this.httpMethod, 21 | required this.path, 22 | required this.response, 23 | required this.body, 24 | required this.pathParameters, 25 | required this.queryParameters, 26 | }); 27 | 28 | bool get isAsync => response.isAsync; 29 | 30 | bool get isStream => response.responseType.isStream; 31 | } 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://www.dartlang.org/guides/libraries/private-files 2 | 3 | # Files and directories created by pub 4 | .dart_tool/ 5 | .packages 6 | build/ 7 | # If you're building an application, you may want to check-in your pubspec.lock 8 | pubspec.lock 9 | 10 | # Directory created by dartdoc 11 | # If you don't generate documentation locally you can remove this line. 12 | doc/api/ 13 | 14 | # dotenv environment variables file 15 | .env* 16 | 17 | # Avoid committing generated Javascript files: 18 | *.dart.js 19 | *.info.json # Produced by the --dump-info flag. 20 | *.js # When generated by dart2js. Don't specify *.js if your 21 | # project includes source files written in JavaScript. 22 | *.js_ 23 | *.js.deps 24 | *.js.map 25 | 26 | .flutter-plugins 27 | .flutter-plugins-dependencies 28 | 29 | # Generated build files 30 | *.g.dart 31 | *.api.dart 32 | *.client.dart 33 | *.mocks.dart 34 | 35 | # Intellij files 36 | .idea/ 37 | *.iml 38 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/example/basic_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | @immutable 4 | class BasicModel { 5 | final int value; 6 | 7 | const BasicModel(this.value); 8 | 9 | factory BasicModel.fromJson(Map json) => 10 | BasicModel(json['value'] as int); 11 | 12 | dynamic toJson() => {'value': value}; 13 | 14 | @override 15 | String toString() => 'BasicModel($value)'; 16 | 17 | @override 18 | bool operator ==(Object other) { 19 | if (identical(this, other)) { 20 | return true; 21 | } else if (other is! BasicModel) { 22 | return false; 23 | } else { 24 | return value == other.value; 25 | } 26 | } 27 | 28 | @override 29 | int get hashCode => value.hashCode; 30 | 31 | // ignore: prefer_constructors_over_static_methods for code generation 32 | static BasicModel fromJsonX(dynamic value) => BasicModel(value as int); 33 | 34 | static int toJsonX(BasicModel model) => model.value; 35 | } 36 | -------------------------------------------------------------------------------- /packages/shelf_api/example/riverpod_request_handler.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:shelf/shelf.dart'; 4 | import 'package:shelf_api/shelf_api.dart'; 5 | 6 | import 'date_time_provider.dart'; 7 | 8 | Future riverpodRequestHandler(Request request) async { 9 | final delay = int.parse(request.url.queryParameters['delay'] ?? '0'); 10 | final mode = request.url.queryParameters['mode']; 11 | 12 | final timestamp = switch (mode) { 13 | 'singleton' => request.ref.read(dateTimeSingletonProvider), 14 | 'factory' => request.ref.read(dateTimeFactoryProvider), 15 | 'requestSingleton' => request.ref.read(requestDateTimeSingletonProvider), 16 | 'requestFactory' => request.ref.read(requestDateTimeFactoryProvider), 17 | _ => null, 18 | }; 19 | 20 | await Future.delayed(Duration(milliseconds: delay)); 21 | 22 | return Response( 23 | timestamp == null ? HttpStatus.badRequest : HttpStatus.ok, 24 | body: timestamp?.toIso8601String(), 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/src/annotations/shelf_api.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:meta/meta_meta.dart'; 4 | import 'package:shelf/shelf.dart'; 5 | 6 | import 'api_endpoint.dart'; 7 | 8 | /// Annotation to mark a class as shelf API router 9 | @Target({TargetKind.classType}) 10 | class ShelfApi { 11 | /// The list of endpoint classes the API is composed of 12 | final List endpoints; 13 | 14 | /// The base API path to prepend to each API route. 15 | /// 16 | /// If left empty, no prefix is added. 17 | final String? basePath; 18 | 19 | /// Optional middleware to be applied to the API. 20 | /// 21 | /// If specified, this function must return a [Middleware], which is applied 22 | /// to all requests to this API. 23 | /// 24 | /// To set a middleware for a specific endpoint, use [ApiEndpoint.middleware]. 25 | final Middleware Function()? middleware; 26 | 27 | /// Constructor 28 | const ShelfApi(this.endpoints, {this.basePath, this.middleware}); 29 | } 30 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "dart", 6 | "command": "dart", 7 | "cwd": "packages/shelf_api", 8 | "args": [ 9 | "run", 10 | "build_runner", 11 | "watch" 12 | ], 13 | "problemMatcher": [], 14 | "label": "dart: build shelf_api", 15 | "detail": "packages/shelf_api" 16 | }, 17 | { 18 | "type": "dart", 19 | "command": "dart", 20 | "cwd": "packages/shelf_api_builder", 21 | "args": [ 22 | "run", 23 | "build_runner", 24 | "watch" 25 | ], 26 | "problemMatcher": [], 27 | "label": "dart: build shelf_api_builder", 28 | "detail": "packages/shelf_api_builder" 29 | }, 30 | { 31 | "label": "dart: dart run build_runner watch", 32 | "detail": "workspace", 33 | "dependsOn": [ 34 | "dart: build shelf_api", 35 | "dart: build shelf_api_builder", 36 | ], 37 | "group": { 38 | "kind": "build", 39 | "isDefault": true 40 | }, 41 | "problemMatcher": [] 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/shelf_api_builder_ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI/CD for shelf_api_builder 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - "**" 8 | paths: 9 | - "packages/shelf_api_builder/**" 10 | - ".github/workflows/shelf_api_builder_ci.yaml" 11 | - ".github/workflows/shelf_api_builder_cd.yaml" 12 | 13 | jobs: 14 | ci: 15 | name: CI 16 | uses: Skycoder42/dart_test_tools/.github/workflows/dart.yml@main 17 | with: 18 | workingDirectory: packages/shelf_api_builder 19 | buildDependencies: shelf_api 20 | buildRunner: true 21 | panaScoreThreshold: 10 22 | unitTestPaths: "" 23 | integrationTestPaths: -P integration 24 | 25 | cd: 26 | name: CD 27 | uses: Skycoder42/dart_test_tools/.github/workflows/release.yml@main 28 | needs: 29 | - ci 30 | with: 31 | workingDirectory: packages/shelf_api_builder 32 | tagPrefix: shelf_api_builder-v 33 | secrets: 34 | githubToken: ${{ secrets.GH_PAT }} 35 | -------------------------------------------------------------------------------- /.github/workflows/shelf_api_ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI/CD for shelf_api 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - "**" 8 | paths: 9 | - "packages/shelf_api/**" 10 | - ".github/workflows/shelf_api_ci.yaml" 11 | - ".github/workflows/shelf_api_cd.yaml" 12 | 13 | jobs: 14 | ci: 15 | name: CI 16 | uses: Skycoder42/dart_test_tools/.github/workflows/dart.yml@main 17 | with: 18 | workingDirectory: packages/shelf_api 19 | buildRunner: true 20 | unitTestPaths: -P unit 21 | minCoverage: 0 # WORKAROUND for https://github.com/dart-lang/test/issues/2570 22 | coverageExclude: >- 23 | "**/*.g.dart" 24 | integrationTestPaths: -P integration 25 | 26 | cd: 27 | name: CD 28 | uses: Skycoder42/dart_test_tools/.github/workflows/release.yml@main 29 | needs: 30 | - ci 31 | with: 32 | workingDirectory: packages/shelf_api 33 | tagPrefix: shelf_api-v 34 | secrets: 35 | githubToken: ${{ secrets.GH_PAT }} 36 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/readers/serializable_reader.dart: -------------------------------------------------------------------------------- 1 | import 'package:build/build.dart'; 2 | import 'package:meta/meta.dart'; 3 | import 'package:source_gen/source_gen.dart'; 4 | 5 | import '../models/opaque_constant.dart'; 6 | 7 | @internal 8 | mixin SerializableReader { 9 | ConstantReader get constantReader; 10 | 11 | bool get hasFromJson => !constantReader.read('fromJson').isNull; 12 | 13 | Future fromJson(BuildStep buildStep) async { 14 | final fromJsonReader = constantReader.read('fromJson'); 15 | return fromJsonReader.isNull 16 | ? null 17 | : await OpaqueConstant.revived(buildStep, fromJsonReader.revive()); 18 | } 19 | 20 | bool get hasToJson => !constantReader.read('toJson').isNull; 21 | 22 | Future toJson(BuildStep buildStep) async { 23 | final toJsonReader = constantReader.read('toJson'); 24 | return toJsonReader.isNull 25 | ? null 26 | : await OpaqueConstant.revived(buildStep, toJsonReader.revive()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/readers/stringifiable_reader.dart: -------------------------------------------------------------------------------- 1 | import 'package:build/build.dart'; 2 | import 'package:meta/meta.dart'; 3 | import 'package:source_gen/source_gen.dart'; 4 | 5 | import '../models/opaque_constant.dart'; 6 | 7 | @internal 8 | mixin StringifiableReader { 9 | ConstantReader get constantReader; 10 | 11 | bool get hasParse => !constantReader.read('parse').isNull; 12 | 13 | Future parse(BuildStep buildStep) async { 14 | final parseReader = constantReader.read('parse'); 15 | return parseReader.isNull 16 | ? null 17 | : await OpaqueConstant.revived(buildStep, parseReader.revive()); 18 | } 19 | 20 | bool get hasStringify => !constantReader.read('stringify').isNull; 21 | 22 | Future stringify(BuildStep buildStep) async { 23 | final stringifyReader = constantReader.read('stringify'); 24 | return stringifyReader.isNull 25 | ? null 26 | : await OpaqueConstant.revived(buildStep, stringifyReader.revive()); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/shelf_api/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: shelf_api 2 | description: A package to declare rest API endpoints that generate into shelf handlers. 3 | version: 1.4.1 4 | homepage: https://github.com/Skycoder42/shelf_api 5 | topics: 6 | - server 7 | - shelf 8 | - dio 9 | - rest 10 | - api 11 | 12 | environment: 13 | sdk: ^3.10.0 14 | resolution: workspace 15 | 16 | dependencies: 17 | dio: ^5.9.0 18 | meta: ^1.17.0 19 | riverpod: ^3.0.3 20 | riverpod_annotation: ^3.0.3 21 | rxdart: ^0.28.0 22 | shelf: ^1.4.2 23 | shelf_router: ^1.1.4 24 | 25 | dev_dependencies: 26 | build_runner: ^2.10.4 27 | dart_pre_commit: ^6.1.0 28 | dart_test_tools: ^7.0.1 29 | mockito: ^5.6.1 30 | riverpod_generator: ^3.0.3 31 | stream_channel: ^2.1.4 32 | test: ^1.26.3 33 | 34 | cider: 35 | link_template: 36 | tag: https://github.com/Skycoder42/shelf_api/releases/tag/shelf_api-v%tag% 37 | diff: https://github.com/Skycoder42/shelf_api/compare/shelf_api-v%from%...shelf_api-v%to% 38 | 39 | dart_pre_commit: 40 | pull-up-dependencies: 41 | allowed: 42 | - meta 43 | - shelf 44 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/readers/shelf_api_reader.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/element/type.dart'; 2 | import 'package:meta/meta.dart'; 3 | import 'package:source_gen/source_gen.dart'; 4 | 5 | import '../util/type_checkers.dart'; 6 | import 'middleware_reader.dart'; 7 | 8 | @internal 9 | class ShelfApiReader with MiddlewareReader { 10 | @override 11 | final ConstantReader constantReader; 12 | 13 | ShelfApiReader(this.constantReader) { 14 | if (!constantReader.instanceOf(TypeCheckers.shelfApi)) { 15 | throw ArgumentError.value( 16 | constantReader, 17 | 'constantReader', 18 | 'Can only apply ShelfApiReader on ShelfApi annotations.', 19 | ); 20 | } 21 | } 22 | 23 | List get endpoints => constantReader 24 | .read('endpoints') 25 | .listValue 26 | .map(ConstantReader.new) 27 | .map((r) => r.typeValue) 28 | .toList(); 29 | 30 | String? get basePath { 31 | final basePathReader = constantReader.read('basePath'); 32 | return basePathReader.isNull ? null : basePathReader.stringValue; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/shelf_api/test/unit/api/http_method_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_test_tools/test.dart'; 2 | import 'package:shelf_api/src/api/http_method.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | void main() { 6 | group('HttpMethod', () { 7 | testData<(String, String)>( 8 | 'provides correct verbs', 9 | [ 10 | (HttpMethod.connect, 'CONNECT'), 11 | (HttpMethod.delete, 'DELETE'), 12 | (HttpMethod.get, 'GET'), 13 | (HttpMethod.head, 'HEAD'), 14 | (HttpMethod.options, 'OPTIONS'), 15 | (HttpMethod.patch, 'PATCH'), 16 | (HttpMethod.post, 'POST'), 17 | (HttpMethod.put, 'PUT'), 18 | (HttpMethod.trace, 'TRACE'), 19 | ], 20 | (fixture) { 21 | expect(fixture.$1, fixture.$2); 22 | }, 23 | ); 24 | 25 | test('value report all verbs', () { 26 | expect(HttpMethod.values, [ 27 | 'CONNECT', 28 | 'DELETE', 29 | 'GET', 30 | 'HEAD', 31 | 'OPTIONS', 32 | 'PATCH', 33 | 'POST', 34 | 'PUT', 35 | 'TRACE', 36 | ]); 37 | }); 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/readers/api_endpoint_reader.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/element/element.dart'; 2 | import 'package:meta/meta.dart'; 3 | import 'package:source_gen/source_gen.dart'; 4 | 5 | import '../util/type_checkers.dart'; 6 | import 'middleware_reader.dart'; 7 | 8 | @internal 9 | class ApiEndpointReader with MiddlewareReader { 10 | @override 11 | final ConstantReader constantReader; 12 | 13 | ApiEndpointReader(this.constantReader) { 14 | if (!constantReader.instanceOf(TypeCheckers.apiEndpoint)) { 15 | throw ArgumentError.value( 16 | constantReader, 17 | 'constantReader', 18 | 'Can only apply ApiEndpointReader on ApiEndpoint annotations.', 19 | ); 20 | } 21 | } 22 | 23 | String get path => constantReader.read('path').stringValue; 24 | } 25 | 26 | @internal 27 | extension ApiEndpointElementX on ClassElement { 28 | ApiEndpointReader? get apiEndpointAnnotation { 29 | final annotation = ConstantReader( 30 | TypeCheckers.apiEndpoint.firstAnnotationOf(this), 31 | ); 32 | return annotation.isNull ? null : ApiEndpointReader(annotation); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/readers/path_param_reader.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/element/element.dart'; 2 | import 'package:meta/meta.dart'; 3 | import 'package:source_gen/source_gen.dart'; 4 | 5 | import '../util/type_checkers.dart'; 6 | import 'stringifiable_reader.dart'; 7 | 8 | @internal 9 | class PathParamReader with StringifiableReader { 10 | @override 11 | final ConstantReader constantReader; 12 | 13 | PathParamReader(this.constantReader) { 14 | if (!constantReader.instanceOf(TypeCheckers.pathParam)) { 15 | throw ArgumentError.value( 16 | constantReader, 17 | 'constantReader', 18 | 'Can only apply PathParamReader on PathParam annotations.', 19 | ); 20 | } 21 | } 22 | 23 | bool get urlEncode => constantReader.read('urlEncode').boolValue; 24 | } 25 | 26 | @internal 27 | extension PathParamElementX on FormalParameterElement { 28 | PathParamReader? get pathParamAnnotation { 29 | final annotation = ConstantReader( 30 | TypeCheckers.pathParam.firstAnnotationOf(this), 31 | ); 32 | return annotation.isNull ? null : PathParamReader(annotation); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/example/example_api.dart: -------------------------------------------------------------------------------- 1 | import 'package:shelf/shelf.dart'; 2 | import 'package:shelf_api/shelf_api.dart'; 3 | 4 | import 'endpoints/basic_endpoint.dart'; 5 | import 'endpoints/body_endpoint.dart'; 6 | import 'endpoints/middleware_endpoint.dart'; 7 | import 'endpoints/params_endpoint.dart'; 8 | import 'endpoints/response_endpoint.dart'; 9 | import 'endpoints/routing_endpoint.dart'; 10 | 11 | @ShelfApi( 12 | [ 13 | BasicEndpoint, 14 | RootRoutingEndpoint, 15 | OpenRoutingEndpoint, 16 | ClosedRoutingEndpoint, 17 | SlashRoutingEndpoint, 18 | ResponseEndpoint, 19 | ParamsEndpoint, 20 | BodyEndpoint, 21 | MiddlewareEndpoint, 22 | ], 23 | basePath: '/api/v1/', 24 | middleware: apiMiddleware, 25 | ) 26 | // ignore: unused_element for api definition 27 | class _ExampleApi {} 28 | 29 | Middleware apiMiddleware() => 30 | (next) => (request) async { 31 | final response = await next(request); 32 | return response.change( 33 | headers: { 34 | 'X-Api': 'shelf_api', 35 | 'X-Middleware': ['Api', ...?response.headersAll['X-Middleware']], 36 | }, 37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/readers/api_method_reader.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/element/element.dart'; 2 | import 'package:meta/meta.dart'; 3 | import 'package:source_gen/source_gen.dart'; 4 | 5 | import '../util/type_checkers.dart'; 6 | import 'serializable_reader.dart'; 7 | 8 | @internal 9 | class ApiMethodReader with SerializableReader { 10 | @override 11 | final ConstantReader constantReader; 12 | 13 | ApiMethodReader(this.constantReader) { 14 | if (!constantReader.instanceOf(TypeCheckers.apiMethod)) { 15 | throw ArgumentError.value( 16 | constantReader, 17 | 'constantReader', 18 | 'Can only apply ApiMethodReader on ApiMethod annotations.', 19 | ); 20 | } 21 | } 22 | 23 | String get method => constantReader.read('method').stringValue; 24 | 25 | String get path => constantReader.read('path').stringValue; 26 | } 27 | 28 | @internal 29 | extension ApiMethodElementX on MethodElement { 30 | ApiMethodReader? get apiMethodAnnotation { 31 | final annotation = ConstantReader( 32 | TypeCheckers.apiMethod.firstAnnotationOf(this), 33 | ); 34 | return annotation.isNull ? null : ApiMethodReader(annotation); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/models/endpoint_query_parameter.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | import 'opaque_constant.dart'; 4 | import 'opaque_type.dart'; 5 | 6 | @internal 7 | class EndpointQueryParameter { 8 | final String paramName; 9 | final String queryName; 10 | final OpaqueType type; 11 | final bool isString; 12 | final bool isEnum; 13 | final bool isDateTime; 14 | final bool isList; 15 | final bool isOptional; 16 | final String? defaultValue; 17 | final OpaqueConstant? customParse; 18 | final OpaqueConstant? customToString; 19 | 20 | EndpointQueryParameter({ 21 | required this.paramName, 22 | required this.queryName, 23 | required this.type, 24 | required this.isString, 25 | required this.isEnum, 26 | required this.isDateTime, 27 | required this.isList, 28 | required this.isOptional, 29 | required this.defaultValue, 30 | required this.customParse, 31 | required this.customToString, 32 | }) { 33 | if (isList && isOptional && defaultValue == null) { 34 | throw StateError('Optional list params cannot be nullable!'); 35 | } 36 | } 37 | 38 | String get handlerParamName => '\$query\$$paramName'; 39 | } 40 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Flutter (Stable)", 3 | "image": "skycoder42/devcontainers-flutter:latest", 4 | "customizations": { 5 | "vscode": { 6 | "extensions": [ 7 | "blaugold.melos-code", 8 | "blaxou.freezed", 9 | "dart-code.dart-code", 10 | "github.vscode-github-actions", 11 | "Gruntfuggly.todo-tree", 12 | "mhutchie.git-graph", 13 | "ms-vscode.live-server", 14 | "redhat.vscode-yaml", 15 | "robert-brunhage.flutter-riverpod-snippets", 16 | "streetsidesoftware.code-spell-checker", 17 | "streetsidesoftware.code-spell-checker-german", 18 | "timonwong.shellcheck" 19 | ], 20 | "settings": { 21 | "dart.sdkPath": "/home/vscode/flutter/bin/cache/dart-sdk", 22 | "dart.flutterSdkPath": "/home/vscode/flutter", 23 | "terminal.integrated.defaultProfile.linux": "zsh" 24 | } 25 | } 26 | }, 27 | "features": { 28 | "ghcr.io/devcontainers-extra/features/zsh-plugins:0": { 29 | "plugins": "git colorize vscode", 30 | "omzPlugins": "https://github.com/zsh-users/zsh-autosuggestions" 31 | }, 32 | "ghcr.io/stuartleeks/dev-container-features/shell-history:0": {} 33 | }, 34 | "postCreateCommand": "./tool/setup_git_hooks.dart" 35 | } 36 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/shelf_api_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:build/build.dart'; 2 | import 'package:source_gen/source_gen.dart'; 3 | 4 | import 'src/client_generator.dart'; 5 | import 'src/endpoint_generator.dart'; 6 | 7 | /// The [EndpointGenerator] builder. 8 | /// 9 | /// It supports the following configuration options: 10 | /// Key | Type | Default Value | Description 11 | ///------------------|--------|---------------|------------- 12 | /// `generateApi` | `bool` | `true` | Enables or disables it 13 | Builder shelfApiBuilder(BuilderOptions options) => LibraryBuilder( 14 | EndpointGenerator(options), 15 | generatedExtension: '.api.dart', 16 | options: options, 17 | ); 18 | 19 | /// The [ClientGenerator] builder 20 | /// 21 | /// It supports the following configuration options: 22 | /// Key | Type | Default Value | Description 23 | ///---------------------|--∞------|---------------|------------- 24 | /// `generateClient` | `bool` | `true` | Enables or disables it 25 | Builder shelfApiClientBuilder(BuilderOptions options) => LibraryBuilder( 26 | ClientGenerator(options), 27 | generatedExtension: '.client.dart', 28 | options: options, 29 | ); 30 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/readers/query_param_reader.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/element/element.dart'; 2 | import 'package:meta/meta.dart'; 3 | import 'package:source_gen/source_gen.dart'; 4 | 5 | import '../util/type_checkers.dart'; 6 | import 'stringifiable_reader.dart'; 7 | 8 | @internal 9 | class QueryParamReader with StringifiableReader { 10 | @override 11 | final ConstantReader constantReader; 12 | 13 | QueryParamReader(this.constantReader) { 14 | if (!constantReader.instanceOf(TypeCheckers.queryParam)) { 15 | throw ArgumentError.value( 16 | constantReader, 17 | 'constantReader', 18 | 'Can only apply QueryParamReader on QueryParam annotations.', 19 | ); 20 | } 21 | } 22 | 23 | String? get name { 24 | final nameReader = constantReader.read('name'); 25 | return nameReader.isNull ? null : nameReader.stringValue; 26 | } 27 | } 28 | 29 | @internal 30 | extension QueryParamElementX on FormalParameterElement { 31 | QueryParamReader? get queryParamAnnotation { 32 | final annotation = ConstantReader( 33 | TypeCheckers.queryParam.firstAnnotationOf(this), 34 | ); 35 | return annotation.isNull ? null : QueryParamReader(annotation); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/util/code/switch.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | // ignore: implementation_imports for private APIs 3 | import 'package:code_builder/src/specs/code.dart' show CodeVisitor; 4 | import 'package:meta/meta.dart'; 5 | 6 | @internal 7 | class Switch implements Code { 8 | final Expression _condition; 9 | final _cases = <(Expression, Code?)>[]; 10 | Code? defaultCase; 11 | 12 | Switch(this._condition); 13 | 14 | void addCase(Expression expression, [Code? body]) => 15 | _cases.add((expression, body)); 16 | 17 | @override 18 | R accept(covariant CodeVisitor visitor, [R? context]) => 19 | Block.of(_build()).accept(visitor, context); 20 | 21 | Iterable _build() sync* { 22 | yield const Code('switch('); 23 | yield _condition.code; 24 | yield const Code('){'); 25 | for (final (condition, body) in _cases) { 26 | yield const Code('case '); 27 | yield condition.code; 28 | yield const Code(':'); 29 | if (body != null) { 30 | yield body; 31 | } 32 | } 33 | if (defaultCase case final Code body) { 34 | yield const Code('default:'); 35 | yield body; 36 | } 37 | yield const Code('}'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/example/main.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print in example code 2 | 3 | import 'dart:async'; 4 | import 'dart:io'; 5 | 6 | import 'package:shelf/shelf.dart'; 7 | import 'package:shelf/shelf_io.dart'; 8 | import 'package:shelf_api/shelf_api.dart'; 9 | import 'example_api.api.dart'; 10 | 11 | void main(List args) async { 12 | final port = int.parse(args.firstOrNull ?? '8080'); 13 | 14 | final app = const Pipeline() 15 | .addMiddleware(handleFormatExceptions()) 16 | .addMiddleware(logRequests()) 17 | .addMiddleware(rivershelf()) 18 | .addHandler(ExampleApi().call); 19 | 20 | final server = await serve(app, 'localhost', port); 21 | print('Serving at http://${server.address.host}:${server.port}'); 22 | 23 | final signals = [ 24 | ProcessSignal.sigint, 25 | if (!Platform.isWindows) ProcessSignal.sigterm, 26 | ]; 27 | final subs = >[]; 28 | for (final signal in signals) { 29 | subs.add( 30 | signal.watch().listen((signal) { 31 | for (final sub in subs) { 32 | unawaited(sub.cancel()); 33 | } 34 | print('Received $signal - terminating server'); 35 | unawaited(server.close()); 36 | }), 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: shelf_api_builder 2 | description: A code generator to create RESTful API endpoints to be integrated with shelf. 3 | version: 1.3.1 4 | homepage: https://github.com/Skycoder42/shelf_api 5 | topics: 6 | - server 7 | - shelf 8 | - dio 9 | - rest 10 | - api 11 | 12 | environment: 13 | sdk: ^3.10.0 14 | resolution: workspace 15 | 16 | platforms: 17 | linux: 18 | macos: 19 | windows: 20 | 21 | dependencies: 22 | analyzer: ">=8.4.0 <10.0.0" 23 | build: ^4.0.3 24 | build_config: ^1.2.0 25 | code_builder: ^4.11.0 26 | meta: ^1.17.0 27 | path: ^1.9.1 28 | shelf: ^1.4.2 29 | shelf_api: ^1.4.1 30 | source_gen: ^4.1.1 31 | source_helper: ^1.3.8 32 | 33 | dev_dependencies: 34 | build_runner: ^2.10.4 35 | dart_pre_commit: ^6.1.0 36 | dart_test_tools: ^7.0.1 37 | dio: ^5.9.0 38 | source_gen_test: ^1.3.2 39 | test: ^1.26.3 40 | 41 | cider: 42 | link_template: 43 | tag: https://github.com/Skycoder42/shelf_api/releases/tag/shelf_api_builder-v%tag% 44 | diff: https://github.com/Skycoder42/shelf_api/compare/shelf_api_builder-v%from%...shelf_api_builder-v%to% 45 | 46 | dart_pre_commit: 47 | pull-up-dependencies: 48 | allowed: 49 | - analyzer 50 | - meta 51 | - path 52 | - shelf 53 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/util/code/if.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | // ignore: implementation_imports for private apis 3 | import 'package:code_builder/src/specs/code.dart' show CodeVisitor; 4 | import 'package:meta/meta.dart'; 5 | 6 | @internal 7 | class If implements Code { 8 | final List<(Expression, Code)> _branches; 9 | Code? orElse; 10 | 11 | If(Expression condition, Code body) : _branches = [(condition, body)]; 12 | 13 | void elif(Expression condition, Code body) { 14 | _branches.add((condition, body)); 15 | } 16 | 17 | @override 18 | R accept(covariant CodeVisitor visitor, [R? context]) => 19 | Block.of(_build()).accept(visitor, context); 20 | 21 | Iterable _build() sync* { 22 | yield const Code('if('); 23 | yield _branches.first.$1.code; 24 | yield const Code('){'); 25 | yield _branches.first.$2; 26 | yield const Code('}'); 27 | 28 | for (final elif in _branches.skip(1)) { 29 | yield const Code('else if('); 30 | yield elif.$1.code; 31 | yield const Code('){'); 32 | yield elif.$2; 33 | yield const Code('}'); 34 | } 35 | 36 | if (orElse case final Code body) { 37 | yield const Code('else{'); 38 | yield body; 39 | yield const Code('}'); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/api/path_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | import '../../models/endpoint_path_parameter.dart'; 5 | import '../../models/opaque_constant.dart'; 6 | import '../../util/constants.dart'; 7 | import '../../util/types.dart'; 8 | 9 | @internal 10 | final class PathBuilder { 11 | final List _pathParameters; 12 | 13 | const PathBuilder(this._pathParameters); 14 | 15 | Iterable build() sync* { 16 | for (final param in _pathParameters) { 17 | Expression paramRef = refer(param.handlerParamName); 18 | 19 | if (param.urlEncode) { 20 | paramRef = Types.uri.property('decodeComponent').call([paramRef]); 21 | } 22 | 23 | if (param.customParse case final OpaqueConstant parse) { 24 | yield Constants.fromConstant(parse).call([paramRef]); 25 | } else if (param.isString) { 26 | yield paramRef; 27 | } else if (param.isEnum) { 28 | yield Types.fromType( 29 | param.type, 30 | isNull: false, 31 | ).property('values').property('byName').call([paramRef]); 32 | } else { 33 | yield Types.fromType( 34 | param.type, 35 | isNull: false, 36 | ).newInstanceNamed('parse', [paramRef]); 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/analyzers/api_class_analyzer.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/element/element.dart'; 2 | import 'package:build/build.dart'; 3 | import 'package:meta/meta.dart'; 4 | import 'package:source_gen/source_gen.dart'; 5 | 6 | import '../models/api_class.dart'; 7 | import '../models/opaque_type.dart'; 8 | import '../readers/shelf_api_reader.dart'; 9 | import 'endpoint_analyzer.dart'; 10 | 11 | @internal 12 | class ApiClassAnalyzer { 13 | final BuildStep _buildStep; 14 | final EndpointAnalyzer _endpointAnalyzer; 15 | 16 | ApiClassAnalyzer(this._buildStep) 17 | : _endpointAnalyzer = EndpointAnalyzer(_buildStep); 18 | 19 | Future analyzeApiClass( 20 | ClassElement clazz, 21 | ShelfApiReader shelfApi, 22 | ) async { 23 | if (shelfApi.endpoints.isEmpty) { 24 | throw InvalidGenerationSourceError( 25 | 'The ShelfApi annotation must define at least one endpoint.', 26 | element: clazz, 27 | ); 28 | } 29 | 30 | return ApiClass( 31 | classType: OpaqueClassType(_buildStep, clazz), 32 | className: clazz.name!, 33 | endpoints: [ 34 | for (final endpoint in shelfApi.endpoints) 35 | await _endpointAnalyzer.analyzeEndpoint(endpoint, clazz), 36 | ], 37 | basePath: shelfApi.basePath, 38 | middleware: await shelfApi.middleware(_buildStep), 39 | ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/readers/body_param_reader.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/element/element.dart'; 2 | import 'package:meta/meta.dart'; 3 | import 'package:source_gen/source_gen.dart'; 4 | 5 | import '../util/type_checkers.dart'; 6 | import 'serializable_reader.dart'; 7 | 8 | @internal 9 | class BodyParamReader with SerializableReader { 10 | @override 11 | final ConstantReader constantReader; 12 | 13 | BodyParamReader(this.constantReader) { 14 | if (!constantReader.instanceOf(TypeCheckers.bodyParam)) { 15 | throw ArgumentError.value( 16 | constantReader, 17 | 'constantReader', 18 | 'Can only apply BodyParamReader on BodyParam annotations.', 19 | ); 20 | } 21 | } 22 | 23 | List? get contentTypes { 24 | final contentTypesReader = constantReader.read('contentTypes'); 25 | return contentTypesReader.isNull 26 | ? null 27 | : contentTypesReader.listValue 28 | .map(ConstantReader.new) 29 | .map((r) => r.stringValue) 30 | .toList(); 31 | } 32 | } 33 | 34 | @internal 35 | extension BodyParamElementX on FormalParameterElement { 36 | BodyParamReader? get bodyParamAnnotation { 37 | final annotation = ConstantReader( 38 | TypeCheckers.bodyParam.firstAnnotationOf(this), 39 | ); 40 | return annotation.isNull ? null : BodyParamReader(annotation); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/models/endpoint_body.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | import 'opaque_type.dart'; 4 | import 'serializable_type.dart'; 5 | 6 | @internal 7 | enum EndpointBodyType { 8 | text, 9 | binary, 10 | textStream, 11 | binaryStream, 12 | json; 13 | 14 | bool get isStream => switch (this) { 15 | EndpointBodyType.textStream || EndpointBodyType.binaryStream => true, 16 | _ => false, 17 | }; 18 | } 19 | 20 | @internal 21 | class EndpointBody { 22 | final OpaqueType paramType; 23 | final EndpointBodyType bodyType; 24 | final List contentTypes; 25 | 26 | EndpointBody({ 27 | required this.paramType, 28 | required this.bodyType, 29 | required this.contentTypes, 30 | }) { 31 | if (bodyType == EndpointBodyType.json && 32 | paramType is! OpaqueSerializableType) { 33 | throw ArgumentError( 34 | 'If bodyType is json, paramType must be as $SerializableType', 35 | ); 36 | } 37 | } 38 | 39 | bool get isAsync => !bodyType.isStream; 40 | 41 | SerializableType get serializableParamType { 42 | if (bodyType != EndpointBodyType.json) { 43 | throw StateError( 44 | 'Cannot get serializableParamType if bodyType is not json', 45 | ); 46 | } 47 | 48 | return paramType.toSerializable( 49 | 'EndpointBody with bodyType json must hold a OpaqueSerializableType', 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/shelf_api/example/main.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print in examples 2 | 3 | import 'dart:async'; 4 | import 'dart:io'; 5 | 6 | import 'package:shelf/shelf.dart'; 7 | import 'package:shelf/shelf_io.dart'; 8 | import 'package:shelf_api/shelf_api.dart'; 9 | import 'package:shelf_router/shelf_router.dart'; 10 | 11 | import 'format_handler.dart'; 12 | import 'riverpod_request_handler.dart'; 13 | 14 | void main(List args) async { 15 | final port = int.parse(args.firstOrNull ?? '8080'); 16 | final router = Router() 17 | ..get('/riverpod', riverpodRequestHandler) 18 | ..get('/format', formatHandler); 19 | 20 | final app = const Pipeline() 21 | .addMiddleware(handleFormatExceptions()) 22 | .addMiddleware(logRequests()) 23 | .addMiddleware(rivershelf()) 24 | .addHandler(router.call); 25 | 26 | final server = await serve(app, 'localhost', port); 27 | print('Serving at http://${server.address.host}:${server.port}'); 28 | 29 | final signals = [ 30 | ProcessSignal.sigint, 31 | if (!Platform.isWindows) ProcessSignal.sigterm, 32 | ]; 33 | final subs = >[]; 34 | for (final signal in signals) { 35 | subs.add( 36 | signal.watch().listen((signal) { 37 | for (final sub in subs) { 38 | unawaited(sub.cancel()); 39 | } 40 | print('Received $signal - terminating server'); 41 | unawaited(server.close()); 42 | }), 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/src/annotations/query_param.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:meta/meta_meta.dart'; 4 | 5 | /// Can be used to add metadata to a query parameter of an endpoint method. 6 | @Target({TargetKind.parameter}) 7 | class QueryParam { 8 | /// The name of the query parameter. 9 | /// 10 | /// By default, the name of the dart parameter is used. You can use this in 11 | /// case the parameter needs to be spelled differently or is not allowed as a 12 | /// dart parameter name. 13 | final String? name; 14 | 15 | /// A custom parse function. 16 | /// 17 | /// By default, types of query parameters are automatically parsed. However, 18 | /// for custom types this means they have to provide a `parse` constructor 19 | /// or static method. In case you cannot add such a constructor, you can use 20 | /// any static or top level method with the following signature: 21 | /// 22 | /// ```dart 23 | /// T Function(String) 24 | /// ``` 25 | final Function? parse; 26 | 27 | /// A custom toString function. 28 | /// 29 | /// By default, path parameters are converted to a string via their 30 | /// [Object.toString] implementation. In case you cannot override the toString 31 | /// method, you can use any static or top level method with the following 32 | /// signature: 33 | /// 34 | /// ```dart 35 | /// String Function(T) 36 | /// ``` 37 | final Function? stringify; 38 | 39 | /// Constructor. 40 | const QueryParam({this.name, this.parse, this.stringify}); 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Felix Barz 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/client/body_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | import '../../models/endpoint_body.dart'; 5 | import '../../models/opaque_constant.dart'; 6 | import '../../util/constants.dart'; 7 | import '../base/expression_builder.dart'; 8 | 9 | @internal 10 | final class BodyBuilder extends ExpressionBuilder { 11 | static const Reference bodyRef = Reference('body'); 12 | 13 | final EndpointBody _body; 14 | 15 | const BodyBuilder(this._body); 16 | 17 | @override 18 | Expression build() { 19 | switch (_body.bodyType) { 20 | case EndpointBodyType.text: 21 | case EndpointBodyType.binary: 22 | case EndpointBodyType.binaryStream: 23 | return bodyRef; 24 | case EndpointBodyType.textStream: 25 | return bodyRef.property('transform').call([ 26 | Constants.utf8.property('encoder'), 27 | ]); 28 | case EndpointBodyType.json: 29 | final serializableType = _body.serializableParamType; 30 | if (serializableType.toJson case final OpaqueConstant toJson) { 31 | if (serializableType.isNullable) { 32 | return bodyRef 33 | .notEqualTo(literalNull) 34 | .conditional( 35 | Constants.fromConstant(toJson).call([bodyRef]), 36 | literalNull, 37 | ); 38 | } else { 39 | return Constants.fromConstant(toJson).call([bodyRef]); 40 | } 41 | } else { 42 | return bodyRef; 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/src/api/handle_format_exceptions.dart: -------------------------------------------------------------------------------- 1 | import 'package:shelf/shelf.dart'; 2 | 3 | /// A key to get the original [FormatException] from [Request.context]. 4 | /// 5 | /// Can only be used if the response was created by the 6 | /// [handleFormatExceptions] middleware. 7 | const handleFormatExceptionsOriginalExceptionKey = 8 | 'handleFormatExceptions.originalException'; 9 | 10 | /// A key to get the original [StackTrace] from [Request.context]. 11 | /// 12 | /// Can only be used if the response was created by the 13 | /// [handleFormatExceptions] middleware. 14 | const handleFormatExceptionsOriginalStackTraceKey = 15 | 'handleFormatExceptions.originalStackTrace'; 16 | 17 | /// A middleware that handles [FormatException]s 18 | /// 19 | /// Automatically returns a [Response.badRequest] with the 20 | /// [FormatException.message] as response body. 21 | /// 22 | /// The original [FormatException] and [StackTrace] will also be stored in 23 | /// the [Response.context] in case further logging should take place. The keys 24 | /// are: [handleFormatExceptionsOriginalExceptionKey] and 25 | /// [handleFormatExceptionsOriginalStackTraceKey] 26 | Middleware handleFormatExceptions() => _HandleFormatExceptionsMiddleware().call; 27 | 28 | class _HandleFormatExceptionsMiddleware { 29 | Handler call(Handler next) => (request) async { 30 | try { 31 | return await next(request); 32 | } on FormatException catch (e, s) { 33 | return Response.badRequest( 34 | body: e.message, 35 | context: { 36 | handleFormatExceptionsOriginalExceptionKey: e, 37 | handleFormatExceptionsOriginalStackTraceKey: s, 38 | }, 39 | ); 40 | } 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/util/code/try.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | // ignore: implementation_imports for private api 3 | import 'package:code_builder/src/specs/code.dart' show CodeVisitor; 4 | import 'package:meta/meta.dart'; 5 | 6 | @internal 7 | class Try implements Code { 8 | final Code _body; 9 | final _catches = <(TypeReference?, Reference?, Reference?, Code)>{}; 10 | Code? finallyBody; 11 | 12 | Try(this._body); 13 | 14 | void addCatch( 15 | Code body, { 16 | TypeReference? on, 17 | Reference? error, 18 | Reference? stackTrace, 19 | }) { 20 | _catches.add((on, error, stackTrace, body)); 21 | } 22 | 23 | @override 24 | R accept(covariant CodeVisitor visitor, [R? context]) => 25 | Block.of(_build()).accept(visitor, context); 26 | 27 | Iterable _build() sync* { 28 | yield const Code('try{'); 29 | yield _body; 30 | yield const Code('}'); 31 | for (final (on, error, stackTrace, body) in _catches) { 32 | if (on != null) { 33 | yield const Code('on '); 34 | yield on.code; 35 | yield const Code(' '); 36 | } 37 | 38 | if (error != null || stackTrace != null || on == null) { 39 | yield const Code('catch('); 40 | yield error?.code ?? const Reference('e').code; 41 | if (stackTrace != null) { 42 | yield const Code(','); 43 | yield stackTrace.code; 44 | } 45 | yield const Code(')'); 46 | } 47 | 48 | yield const Code('{'); 49 | yield body; 50 | yield const Code('}'); 51 | } 52 | 53 | if (finallyBody != null) { 54 | yield const Code('finally{'); 55 | yield finallyBody!; 56 | yield const Code('}'); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/src/annotations/body_param.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:meta/meta_meta.dart'; 4 | 5 | /// Marks the given parameter as the body of the request. 6 | @Target({TargetKind.parameter}) 7 | class BodyParam { 8 | /// A list of allowed content types for this endpoint. 9 | /// 10 | /// By default (when [contentTypes] is `null`), this is detected 11 | /// automatically. If the body is a JSON serializable type, the server will 12 | /// expect the request to have a content type of `application/json`. For all 13 | /// other request body types, no content type validation is done by default. 14 | /// 15 | /// **Note:** You can disable content type validation for JSON requests by 16 | /// setting this property to an empty list `[]`. 17 | final List? contentTypes; 18 | 19 | /// Custom deserialization function for the body. 20 | /// 21 | /// You can use this in case the body type does not have a fromJson 22 | /// constructor or you need to customize the default deserialization behavior 23 | /// for it. It must have the following signature: 24 | /// 25 | /// ```dart 26 | /// T Function(dynamic) 27 | /// ``` 28 | final Function? fromJson; 29 | 30 | /// Custom serialization function for the body. 31 | /// 32 | /// You can use this in case the body type does not have a toJson method 33 | /// or you need to customize the default serialization behavior for it. It 34 | /// must have the following signature: 35 | /// 36 | /// ```dart 37 | /// dynamic Function(T) 38 | /// ``` 39 | final Function? toJson; 40 | 41 | /// Constructor. 42 | const BodyParam({this.contentTypes, this.fromJson, this.toJson}); 43 | } 44 | 45 | /// Marks the given parameter as the body of the request. 46 | const bodyParam = BodyParam(); 47 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/src/annotations/path_param.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:meta/meta_meta.dart'; 4 | 5 | /// Can be used to add metadata to a path parameter of an endpoint method. 6 | @Target({TargetKind.parameter}) 7 | class PathParam { 8 | /// Specifies whether the path parameter should be URL encoded. 9 | /// 10 | /// If enabled, all params are encoded, to ensure special characters (like 11 | /// '/') do not break path resolution. This means that the client will 12 | /// automatically encode all path parameters and the server will automatically 13 | /// decode all parameters. 14 | /// 15 | /// When disabled, no encoding or decoding happens, which allows you to for 16 | /// example use '/' in a path param to actually change the path. However, this 17 | /// can lead to problems with path matching and should be avoided 18 | final bool urlEncode; 19 | 20 | /// A custom parse function. 21 | /// 22 | /// By default, types of path parameters are automatically parsed. However, 23 | /// for custom types this means they have to provide a `parse` constructor 24 | /// or static method. In case you cannot add such a constructor, you can use 25 | /// any static or top level method with the following signature: 26 | /// 27 | /// ```dart 28 | /// T Function(String) 29 | /// ``` 30 | final Function? parse; 31 | 32 | /// A custom toString function. 33 | /// 34 | /// By default, path parameters are converted to a string via their 35 | /// [Object.toString] implementation. In case you cannot override the toString 36 | /// method, you can use any static or top level method with the following 37 | /// signature: 38 | /// 39 | /// ```dart 40 | /// String Function(T) 41 | /// ``` 42 | final Function? stringify; 43 | 44 | /// Constructor. 45 | const PathParam({this.urlEncode = true, this.parse, this.stringify}); 46 | } 47 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/analyzers/methods_analyzer.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/element/element.dart'; 2 | import 'package:build/build.dart'; 3 | import 'package:meta/meta.dart'; 4 | 5 | import '../models/endpoint_method.dart'; 6 | import '../readers/api_method_reader.dart'; 7 | import 'body_analyzer.dart'; 8 | import 'path_analyzer.dart'; 9 | import 'query_analyzer.dart'; 10 | import 'response_analyzer.dart'; 11 | 12 | @internal 13 | class MethodsAnalyzer { 14 | final BodyAnalyzer _bodyAnalyzer; 15 | final PathAnalyzer _pathAnalyzer; 16 | final QueryAnalyzer _queryAnalyzer; 17 | final ResponseAnalyzer _responseAnalyzer; 18 | 19 | MethodsAnalyzer(BuildStep buildStep) 20 | : _bodyAnalyzer = BodyAnalyzer(buildStep), 21 | _pathAnalyzer = PathAnalyzer(buildStep), 22 | _queryAnalyzer = QueryAnalyzer(buildStep), 23 | _responseAnalyzer = ResponseAnalyzer(buildStep); 24 | 25 | Future> analyzeMethods(ClassElement clazz) => 26 | _analyzeMethods(clazz).toList(); 27 | 28 | Stream _analyzeMethods(ClassElement clazz) async* { 29 | for (final method in clazz.methods) { 30 | final apiMethod = method.apiMethodAnnotation; 31 | if (apiMethod == null) { 32 | continue; 33 | } 34 | 35 | yield await _analyzeMethod(method, apiMethod); 36 | } 37 | } 38 | 39 | Future _analyzeMethod( 40 | MethodElement method, 41 | ApiMethodReader apiMethod, 42 | ) async => EndpointMethod( 43 | name: method.name!, 44 | httpMethod: apiMethod.method, 45 | path: apiMethod.path, 46 | pathParameters: await _pathAnalyzer.analyzePath(method, apiMethod), 47 | body: await _bodyAnalyzer.analyzeBody(method), 48 | queryParameters: await _queryAnalyzer.analyzeQuery(method), 49 | response: await _responseAnalyzer.analyzeResponse(method, apiMethod), 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /packages/shelf_api/test/unit/api/handle_format_exceptions_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:mockito/mockito.dart'; 4 | import 'package:shelf/shelf.dart'; 5 | import 'package:shelf_api/src/api/handle_format_exceptions.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | class FakeRequest extends Fake implements Request {} 9 | 10 | class FakeResponse extends Fake implements Response {} 11 | 12 | void main() { 13 | group('FormatExceptionHandlerMiddleware', () { 14 | final testRequest = FakeRequest(); 15 | final testResponse = FakeResponse(); 16 | 17 | late Middleware sut; 18 | 19 | setUp(() { 20 | sut = handleFormatExceptions(); 21 | }); 22 | 23 | test('returns response as is', () { 24 | final sutHandler = sut( 25 | expectAsync1((request) { 26 | expect(request, same(testRequest)); 27 | return testResponse; 28 | }), 29 | ); 30 | expect(sutHandler(testRequest), completion(same(testResponse))); 31 | }); 32 | 33 | test('returns badRequest if handler throws FormatException', () async { 34 | const testException = FormatException('test-message'); 35 | final sutHandler = sut( 36 | expectAsync1((request) { 37 | expect(request, same(testRequest)); 38 | throw testException; 39 | }), 40 | ); 41 | final result = await sutHandler(testRequest); 42 | expect(result.statusCode, HttpStatus.badRequest); 43 | expect(result.readAsString(), completion(testException.message)); 44 | expect( 45 | result.context, 46 | containsPair(handleFormatExceptionsOriginalExceptionKey, testException), 47 | ); 48 | expect( 49 | result.context, 50 | containsPair( 51 | handleFormatExceptionsOriginalStackTraceKey, 52 | isA(), 53 | ), 54 | ); 55 | }); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/example/endpoints/routing_endpoint.dart: -------------------------------------------------------------------------------- 1 | import 'package:shelf/shelf.dart'; 2 | import 'package:shelf_api/shelf_api.dart'; 3 | 4 | class RootRoutingEndpoint extends ShelfEndpoint { 5 | RootRoutingEndpoint(super.request); 6 | 7 | @Head('/') 8 | Response headRoot() => 9 | Response.ok(null, headers: {'X-INFO': _logRequest(request)}); 10 | 11 | @Get('/') 12 | String getRoot() => _logRequest(request); 13 | 14 | @Get('/path/open') 15 | String getPathOpen() => _logRequest(request); 16 | 17 | @Get('/path/closed/') 18 | String getPathClosed() => _logRequest(request); 19 | } 20 | 21 | @ApiEndpoint('/open') 22 | class OpenRoutingEndpoint extends ShelfEndpoint { 23 | OpenRoutingEndpoint(super.request); 24 | 25 | @Delete('/') 26 | String deleteRoot() => _logRequest(request); 27 | 28 | @Options('/path/open') 29 | String optionsPathOpen() => _logRequest(request); 30 | 31 | @Patch('/path/closed/') 32 | String patchPathClosed() => _logRequest(request); 33 | } 34 | 35 | @ApiEndpoint('/closed/') 36 | class ClosedRoutingEndpoint extends ShelfEndpoint { 37 | ClosedRoutingEndpoint(super.request); 38 | 39 | @Post('/') 40 | String postRoot() => _logRequest(request); 41 | 42 | @Put('/path/open') 43 | String putPathOpen() => _logRequest(request); 44 | 45 | @ApiMethod(HttpMethod.trace, '/path/closed/') 46 | String tracePathClosed() => _logRequest(request); 47 | } 48 | 49 | @ApiEndpoint('/') 50 | class SlashRoutingEndpoint extends ShelfEndpoint { 51 | SlashRoutingEndpoint(super.request); 52 | 53 | @Post('/') 54 | String postRoot() => _logRequest(request); 55 | 56 | @Post('/slash/open') 57 | String postSlashOpen() => _logRequest(request); 58 | 59 | @Post('/slash/closed/') 60 | String postSlashClosed() => _logRequest(request); 61 | } 62 | 63 | String _logRequest(Request request) => 64 | '${request.method} ${request.requestedUri.path}'; 65 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/src/client/t_response_body.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | 3 | /// A wrapper around a HTTP response for typed responses. 4 | class TResponseBody { 5 | /// The decoded response body. 6 | final T data; 7 | 8 | /// HTTP status code. 9 | final int statusCode; 10 | 11 | /// Returns the reason phrase corresponds to the status code. 12 | final String? statusMessage; 13 | 14 | /// Whether this response is a redirect. 15 | final bool isRedirect; 16 | 17 | /// Stores redirections during the request. 18 | final List? redirects; 19 | 20 | /// The response headers. 21 | final Headers headers; 22 | 23 | /// Default constructor. 24 | TResponseBody({ 25 | required this.data, 26 | required this.statusCode, 27 | required this.statusMessage, 28 | required this.isRedirect, 29 | required this.redirects, 30 | required this.headers, 31 | }); 32 | 33 | /// Creates a TResponseBody from a [response] and the already decoded [data]. 34 | TResponseBody.fromResponse(Response response, this.data) 35 | : statusCode = response.statusCode ?? 200, 36 | statusMessage = response.statusMessage, 37 | isRedirect = response.isRedirect, 38 | redirects = response.redirects, 39 | headers = response.headers; 40 | 41 | /// Creates a TResponseBody from a [responseBody] and the already decoded 42 | /// [data]. 43 | TResponseBody.fromResponseBody(ResponseBody responseBody, this.data) 44 | : statusCode = responseBody.statusCode, 45 | statusMessage = responseBody.statusMessage, 46 | isRedirect = responseBody.isRedirect, 47 | redirects = responseBody.redirects, 48 | headers = Headers.fromMap(responseBody.headers); 49 | 50 | /// Content length of the response or -1 if not specified 51 | int get contentLength => 52 | int.parse(headers.value(Headers.contentLengthHeader) ?? '-1'); 53 | } 54 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/util/type_checkers.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | import 'package:meta/meta.dart'; 4 | import 'package:shelf/shelf.dart'; 5 | import 'package:shelf_api/shelf_api.dart'; 6 | import 'package:source_gen/source_gen.dart'; 7 | 8 | @internal 9 | abstract base class TypeCheckers { 10 | static const list = TypeChecker.typeNamed(List, inSdk: true); 11 | 12 | static const map = TypeChecker.typeNamed(Map, inSdk: true); 13 | 14 | static const dateTime = TypeChecker.typeNamed(DateTime, inSdk: true); 15 | 16 | static const future = TypeChecker.typeNamed(Future, inSdk: true); 17 | 18 | static const stream = TypeChecker.typeNamed(Stream, inSdk: true); 19 | 20 | static const intList = TypeChecker.typeNamed(List, inSdk: true); 21 | 22 | static const uint8List = TypeChecker.typeNamed(Uint8List, inSdk: true); 23 | 24 | static const response = TypeChecker.typeNamed(Response, inPackage: 'shelf'); 25 | 26 | static const tResponse = TypeChecker.typeNamed( 27 | TResponse, 28 | inPackage: 'shelf_api', 29 | ); 30 | 31 | static const shelfApi = TypeChecker.typeNamed( 32 | ShelfApi, 33 | inPackage: 'shelf_api', 34 | ); 35 | 36 | static const shelfEndpoint = TypeChecker.typeNamed( 37 | ShelfEndpoint, 38 | inPackage: 'shelf_api', 39 | ); 40 | 41 | static const apiEndpoint = TypeChecker.typeNamed( 42 | ApiEndpoint, 43 | inPackage: 'shelf_api', 44 | ); 45 | 46 | static const apiMethod = TypeChecker.typeNamed( 47 | ApiMethod, 48 | inPackage: 'shelf_api', 49 | ); 50 | 51 | static const bodyParam = TypeChecker.typeNamed( 52 | BodyParam, 53 | inPackage: 'shelf_api', 54 | ); 55 | 56 | static const pathParam = TypeChecker.typeNamed( 57 | PathParam, 58 | inPackage: 'shelf_api', 59 | ); 60 | 61 | static const queryParam = TypeChecker.typeNamed( 62 | QueryParam, 63 | inPackage: 'shelf_api', 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/models/endpoint_response.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | 3 | import 'opaque_type.dart'; 4 | import 'serializable_type.dart'; 5 | 6 | @internal 7 | enum EndpointResponseType { 8 | noContent, 9 | text, 10 | binary, 11 | textStream, 12 | binaryStream, 13 | json, 14 | dynamic; 15 | 16 | bool get isStream => switch (this) { 17 | EndpointResponseType.textStream || 18 | EndpointResponseType.binaryStream => true, 19 | _ => false, 20 | }; 21 | } 22 | 23 | @internal 24 | class EndpointResponse { 25 | final EndpointResponseType responseType; 26 | final OpaqueType returnType; 27 | final bool isResponse; 28 | final bool isAsync; 29 | 30 | EndpointResponse({ 31 | required this.responseType, 32 | required this.returnType, 33 | this.isResponse = false, 34 | this.isAsync = false, 35 | }) { 36 | if (responseType == EndpointResponseType.json && 37 | returnType is! OpaqueSerializableType) { 38 | throw ArgumentError( 39 | 'If responseType is json, returnType must be as $SerializableType', 40 | ); 41 | } 42 | } 43 | 44 | EndpointResponse copyWith({ 45 | EndpointResponseType? responseType, 46 | OpaqueType? returnType, 47 | bool? isResponse, 48 | bool? isAsync, 49 | }) => EndpointResponse( 50 | responseType: responseType ?? this.responseType, 51 | returnType: returnType ?? this.returnType, 52 | isResponse: isResponse ?? this.isResponse, 53 | isAsync: isAsync ?? this.isAsync, 54 | ); 55 | 56 | SerializableType get serializableReturnType { 57 | if (responseType != EndpointResponseType.json) { 58 | throw StateError( 59 | 'Cannot get serializableReturnType if responseType is not json', 60 | ); 61 | } 62 | return returnType.toSerializable( 63 | 'EndpointResponse with responseType json must hold a ' 64 | 'OpaqueSerializableType', 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/src/util/stream_extensions.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:rxdart/rxdart.dart'; 5 | import 'package:shelf/shelf.dart'; 6 | 7 | /// Utility extensions used by the code generator 8 | extension ShelfApiStreamX on Stream { 9 | /// Registers a callback to be invoked once the stream is done 10 | Stream onFinished(FutureOr Function() callback) { 11 | var finished = false; 12 | Future finishedCallback() async { 13 | if (finished) { 14 | return; 15 | } 16 | finished = true; 17 | return await callback(); 18 | } 19 | 20 | return cast().transform( 21 | DoStreamTransformer( 22 | onCancel: finishedCallback, 23 | onDone: finishedCallback, 24 | ), 25 | ); 26 | } 27 | } 28 | 29 | /// Utility extensions used by the code generator 30 | extension ShelfApiByteStreamX on Stream> { 31 | /// Collects the data from the stream into a single [Uint8List] 32 | /// 33 | /// If [request] is specified, the method will try to read the 34 | /// `Content-Length` header and pre-allocate memory for a more efficient 35 | /// conversion. 36 | Future collect([Request? request]) async { 37 | if (request != null) { 38 | if (request.contentLength case final int contentLength) { 39 | var offset = 0; 40 | final bytes = Uint8List(contentLength); 41 | await for (final block in this) { 42 | if (offset + block.length > contentLength) { 43 | bytes.setRange(offset, contentLength, block); 44 | break; 45 | } else { 46 | bytes.setAll(offset, block); 47 | offset += block.length; 48 | } 49 | } 50 | 51 | return bytes; 52 | } 53 | } 54 | 55 | return Uint8List.fromList( 56 | await fold([], (previous, element) => previous..addAll(element)), 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/example/endpoints/body_endpoint.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | import 'package:shelf_api/shelf_api.dart'; 4 | 5 | import '../basic_model.dart'; 6 | 7 | @ApiEndpoint('/body') 8 | class BodyEndpoint extends ShelfEndpoint { 9 | BodyEndpoint(super.request); 10 | 11 | @Get('/text') 12 | String getText(@bodyParam String body) => body; 13 | 14 | @Get('/text/custom') 15 | String getTextCustom(@BodyParam(contentTypes: ['text/xml']) String body) => 16 | body; 17 | 18 | @Get('/binary') 19 | Uint8List getBinary(@bodyParam Uint8List body) => body; 20 | 21 | @Get('/stream/text') 22 | Stream streamText(@bodyParam Stream body) => body; 23 | 24 | @Get('/stream/binary') 25 | Stream> streamBinary(@bodyParam Stream body) => body; 26 | 27 | @Get('/json') 28 | BasicModel getJson(@bodyParam BasicModel body) => body; 29 | 30 | @Get('/json/list') 31 | List getJsonList(@bodyParam List body) => body; 32 | 33 | @Get('/json/map') 34 | Map getJsonMap(@bodyParam Map body) => 35 | body; 36 | 37 | @Get('/json/custom') 38 | BasicModel getJsonCustom( 39 | @BodyParam( 40 | contentTypes: ['application/x-json'], 41 | fromJson: BasicModel.fromJsonX, 42 | toJson: BasicModel.toJsonX, 43 | ) 44 | BasicModel body, 45 | ) => body; 46 | 47 | @Get('/json/null') 48 | int? getJsonNull(@bodyParam int? body) => body; 49 | 50 | @Get('/json/null/list') 51 | List? getJsonNullList(@bodyParam List? body) => body; 52 | 53 | @Get('/json/null/map') 54 | Map? getJsonNullMap(@bodyParam Map? body) => body; 55 | 56 | @Get('/json/null/custom') 57 | BasicModel? getJsonNullCustom( 58 | @BodyParam( 59 | contentTypes: [], 60 | fromJson: BasicModel.fromJsonX, 61 | toJson: BasicModel.toJsonX, 62 | ) 63 | BasicModel? body, 64 | ) => body; 65 | } 66 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/src/api/shelf_endpoint.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'dart:async'; 4 | 5 | import 'package:meta/meta.dart'; 6 | import 'package:shelf/shelf.dart'; 7 | 8 | import '../annotations/shelf_api.dart'; 9 | import '../riverpod/endpoint_ref.dart'; 10 | import '../riverpod/rivershelf.dart'; 11 | 12 | /// The base class for all shelf API endpoints. 13 | /// 14 | /// See [ShelfApi] for how to register these endpoints. 15 | abstract class ShelfEndpoint { 16 | /// The original request being processed by this endpoint. 17 | final Request request; 18 | 19 | /// The [EndpointRef] attached to the [request] 20 | final EndpointRef ref; 21 | 22 | /// Default constructor. 23 | /// 24 | /// The [ref] parameter is optional and only made visible for testing purpose. 25 | ShelfEndpoint(this.request, {@visibleForTesting EndpointRef? ref}) 26 | : ref = ref ?? request.ref; 27 | 28 | /// Endpoint initializer callback. 29 | /// 30 | /// Is called by the framework before the actual handler method gets invoked. 31 | /// You can use this method to execute code before every request that will 32 | /// be served by this handler. 33 | /// 34 | /// See [dispose] for a cleanup callback. 35 | @protected 36 | // ignore: avoid_futureor_void for backwards compatibility 37 | FutureOr init() {} 38 | 39 | /// Endpoint finalizer callback. 40 | /// 41 | /// Is called by the framework after the actual handler method was invoked. 42 | /// You can use this method to execute code after every request that will 43 | /// be served by this handler. 44 | /// 45 | /// **Important:** When returning [Stream] results from a handler, this method 46 | /// will be invoked *before* the stream gets consumed! Do not use it for 47 | /// cleanup logic that should run after a streams consumption. 48 | /// 49 | /// See [init] for a setup callback. 50 | @protected 51 | // ignore: avoid_futureor_void for backwards compatibility 52 | FutureOr dispose() {} 53 | } 54 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/client_generator.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/element/element.dart'; 2 | import 'package:build/build.dart'; 3 | import 'package:code_builder/code_builder.dart'; 4 | import 'package:meta/meta.dart'; 5 | import 'package:shelf_api/shelf_api.dart'; 6 | import 'package:source_gen/source_gen.dart'; 7 | 8 | import 'analyzers/api_class_analyzer.dart'; 9 | import 'builders/client/client_builder.dart'; 10 | import 'readers/shelf_api_reader.dart'; 11 | 12 | @internal 13 | class ClientGenerator extends GeneratorForAnnotation { 14 | final BuilderOptions options; 15 | 16 | const ClientGenerator(this.options); 17 | 18 | bool get isEnabled => options.config['generateClient'] as bool? ?? true; 19 | 20 | @override 21 | Future generateForAnnotatedElement( 22 | Element element, 23 | ConstantReader annotation, 24 | BuildStep buildStep, 25 | ) async { 26 | if (!isEnabled) { 27 | return null; 28 | } 29 | 30 | if (element is! ClassElement || !element.isPrivate) { 31 | throw InvalidGenerationSourceError( 32 | 'The $ShelfApi annotation can only be used on private classes.', 33 | element: element, 34 | ); 35 | } 36 | 37 | // analyzers 38 | final shelfApi = ShelfApiReader(annotation); 39 | final apiClassAnalyzer = ApiClassAnalyzer(buildStep); 40 | final apiClass = await apiClassAnalyzer.analyzeApiClass(element, shelfApi); 41 | 42 | final library = Library( 43 | (b) => b 44 | ..ignoreForFile.add('type=lint') 45 | ..ignoreForFile.add('unused_import') 46 | ..directives.add( 47 | Directive.import('package:shelf_api/builder_utils.dart'), 48 | ) 49 | ..body.add(ClientBuilder(apiClass)), 50 | ); 51 | 52 | final emitter = DartEmitter.scoped( 53 | orderDirectives: true, 54 | useNullSafetySyntax: true, 55 | ); 56 | 57 | final buffer = StringBuffer(); 58 | library.accept(emitter, buffer); 59 | return buffer.toString(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/analyzers/endpoint_analyzer.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/element/element.dart'; 2 | import 'package:analyzer/dart/element/type.dart'; 3 | import 'package:build/build.dart'; 4 | import 'package:meta/meta.dart'; 5 | import 'package:source_gen/source_gen.dart'; 6 | 7 | import '../analyzers/methods_analyzer.dart'; 8 | import '../models/endpoint.dart'; 9 | import '../models/opaque_type.dart'; 10 | import '../readers/api_endpoint_reader.dart'; 11 | import '../util/type_checkers.dart'; 12 | 13 | @internal 14 | class EndpointAnalyzer { 15 | final BuildStep _buildStep; 16 | final MethodsAnalyzer _methodsAnalyzer; 17 | 18 | EndpointAnalyzer(this._buildStep) 19 | : _methodsAnalyzer = MethodsAnalyzer(_buildStep); 20 | 21 | Future analyzeEndpoint( 22 | DartType endpointType, 23 | ClassElement apiElement, 24 | ) async { 25 | final endpointElement = endpointType.element; 26 | if (endpointElement is! ClassElement || 27 | !TypeCheckers.shelfEndpoint.isSuperOf(endpointElement)) { 28 | throw InvalidGenerationSource( 29 | 'Endpoints of ShelfApi must extend ShelfEndpoint!', 30 | element: apiElement, 31 | ); 32 | } 33 | 34 | final apiEndpoint = endpointElement.apiEndpointAnnotation; 35 | if (apiEndpoint != null) { 36 | if (apiEndpoint.hasMiddleware && apiEndpoint.path == '/') { 37 | throw InvalidGenerationSource( 38 | 'Endpoints with a middleware must specify a path ' 39 | 'that is different from "/"!', 40 | todo: 41 | 'Use a custom path, move the middleware to the ShelfApi ' 42 | 'or remove it.', 43 | ); 44 | } 45 | } 46 | 47 | return Endpoint( 48 | endpointType: OpaqueClassType(_buildStep, endpointElement), 49 | name: endpointElement.name!, 50 | path: apiEndpoint?.path, 51 | methods: await _methodsAnalyzer.analyzeMethods(endpointElement), 52 | middleware: await apiEndpoint?.middleware(_buildStep), 53 | ); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/src/annotations/api_endpoint.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:meta/meta_meta.dart'; 4 | import 'package:shelf/shelf.dart'; 5 | 6 | import 'shelf_api.dart'; 7 | 8 | /// Marks the given method as a specific endpoint method. 9 | @Target({TargetKind.classType}) 10 | class ApiEndpoint { 11 | /// The base path for all API methods of this endpoint. 12 | /// 13 | /// All methods of this endpoint are mounted below [path], except if the 14 | /// [path] is `'/'`, then they will be mounted directly on the main api 15 | /// router. [path] must always start with a slash and may or may not end with 16 | /// one. See routing table below for how this makes a difference. 17 | /// 18 | /// Example Routing Table: 19 | /// Endpoint Route | Method Route | Resolves to 20 | /// ----------------|--------------|------- 21 | /// *none* | / | / 22 | /// *none* | /users | /users 23 | /// *none* | /users/ | /users/ 24 | /// / | / | / 25 | /// / | /users | /users 26 | /// / | /users/ | /users/ 27 | /// /api | / | /api *or* /api/ 28 | /// /api | /users | /api/users 29 | /// /api | /users/ | /api/users/ 30 | /// /api/ | / | /api/ 31 | /// /api/ | /users | /api/users 32 | /// /api/ | /users/ | /api/users/ 33 | final String path; 34 | 35 | /// Optional middleware to be applied to this endpoint. 36 | /// 37 | /// If specified, this function must return a [Middleware], which is applied 38 | /// to all requests to this endpoint. If the [ShelfApi.middleware] is also 39 | /// set, then that middleware will be applied *before* this one. 40 | /// 41 | /// **Note:** When specifying a middleware, the [path] must not be just `'/'`, 42 | /// as setting a middleware for a root-mounted endpoint is not supported. 43 | final Middleware Function()? middleware; 44 | 45 | /// Constructor. 46 | const ApiEndpoint(this.path, {this.middleware}); 47 | } 48 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/models/opaque_type.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/element/element.dart'; 2 | import 'package:analyzer/dart/element/type.dart'; 3 | import 'package:build/build.dart'; 4 | import 'package:meta/meta.dart'; 5 | import 'package:path/path.dart'; 6 | 7 | import '../util/types.dart'; 8 | import 'serializable_type.dart'; 9 | 10 | @internal 11 | sealed class OpaqueType { 12 | OpaqueType(); 13 | 14 | SerializableType toSerializable(String reason) => switch (this) { 15 | OpaqueSerializableType(serializableType: final type) => type, 16 | _ => throw StateError(reason), 17 | }; 18 | 19 | static Uri? uriForElement(BuildStep buildStep, Element? element) { 20 | final sourceUriStr = Types.getUrlWithFallback(null, element); 21 | if (sourceUriStr == null) { 22 | return null; 23 | } 24 | 25 | final sourceUri = Uri.parse(sourceUriStr); 26 | if (sourceUri.isScheme('asset')) { 27 | final inputPath = posix.dirname( 28 | posix.join(sourceUri.pathSegments.first, buildStep.inputId.path), 29 | ); 30 | final sourcePath = sourceUri.path; 31 | return Uri.file( 32 | posix.relative(sourcePath, from: inputPath), 33 | windows: false, 34 | ); 35 | } else { 36 | return sourceUri; 37 | } 38 | } 39 | } 40 | 41 | @internal 42 | class OpaqueSerializableType extends OpaqueType { 43 | final SerializableType serializableType; 44 | 45 | OpaqueSerializableType(this.serializableType); 46 | } 47 | 48 | @internal 49 | class OpaqueDartType extends OpaqueType { 50 | final DartType dartType; 51 | final Uri? uri; 52 | 53 | OpaqueDartType(BuildStep buildStep, this.dartType) 54 | : uri = OpaqueType.uriForElement(buildStep, dartType.element); 55 | } 56 | 57 | @internal 58 | class OpaqueClassType extends OpaqueType { 59 | final ClassElement element; 60 | final Uri? uri; 61 | 62 | OpaqueClassType(BuildStep buildStep, this.element) 63 | : uri = OpaqueType.uriForElement(buildStep, element); 64 | } 65 | 66 | @internal 67 | class OpaqueDynamicType extends OpaqueType { 68 | OpaqueDynamicType(); 69 | } 70 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/test/integration/test_helper.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print in test code 2 | 3 | import 'dart:async'; 4 | import 'dart:convert'; 5 | import 'dart:io'; 6 | import 'dart:math'; 7 | 8 | import 'package:dio/dio.dart'; 9 | import 'package:test/test.dart'; 10 | 11 | import '../../example/example_api.client.dart'; 12 | 13 | class ExampleServer { 14 | final Process _process; 15 | final Dio dio; 16 | 17 | ExampleServer._(this._process, int port) 18 | : dio = Dio( 19 | BaseOptions( 20 | baseUrl: Uri( 21 | scheme: 'http', 22 | host: 'localhost', 23 | port: port, 24 | path: '/', 25 | ).toString(), 26 | ), 27 | ); 28 | 29 | ExampleApiClient get apiClient => ExampleApiClient.dio(dio); 30 | 31 | static Future start() async { 32 | final port = 8000 + Random.secure().nextInt(999); 33 | final process = await Process.start('dart', [ 34 | 'run', 35 | 'example/main.dart', 36 | port.toString(), 37 | ]); 38 | 39 | try { 40 | process.stderr 41 | .transform(utf8.decoder) 42 | .transform(const LineSplitter()) 43 | .listen((l) => print('ERR: $l')); 44 | 45 | final completer = Completer(); 46 | process.stdout 47 | .transform(utf8.decoder) 48 | .transform(const LineSplitter()) 49 | .map((line) { 50 | if (!completer.isCompleted && line.startsWith('Serving at')) { 51 | completer.complete(null); 52 | } 53 | return line; 54 | }) 55 | .listen((l) => print('OUT: $l')); 56 | 57 | await completer.future.timeout(const Duration(seconds: 15)); 58 | 59 | return ExampleServer._(process, port); 60 | } catch (_) { 61 | process.kill(ProcessSignal.sigkill); 62 | rethrow; 63 | } 64 | } 65 | 66 | Future stop() async { 67 | dio.close(force: true); 68 | 69 | expect(_process.kill(), isTrue); 70 | await _process.exitCode.timeout( 71 | const Duration(seconds: 5), 72 | onTimeout: () { 73 | expect(_process.kill(ProcessSignal.sigkill), isTrue); 74 | return 0; 75 | }, 76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/endpoint_generator.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/element/element.dart'; 2 | import 'package:build/build.dart'; 3 | import 'package:code_builder/code_builder.dart'; 4 | import 'package:meta/meta.dart'; 5 | import 'package:shelf_api/shelf_api.dart'; 6 | import 'package:source_gen/source_gen.dart'; 7 | 8 | import 'analyzers/api_class_analyzer.dart'; 9 | import 'builders/api/api_implementation_builder.dart'; 10 | import 'readers/shelf_api_reader.dart'; 11 | 12 | @internal 13 | class EndpointGenerator extends GeneratorForAnnotation { 14 | final BuilderOptions options; 15 | 16 | const EndpointGenerator(this.options); 17 | 18 | bool get isEnabled => options.config['generateApi'] as bool? ?? true; 19 | 20 | @override 21 | Future generateForAnnotatedElement( 22 | Element element, 23 | ConstantReader annotation, 24 | BuildStep buildStep, 25 | ) async { 26 | if (!isEnabled) { 27 | return null; 28 | } 29 | 30 | if (element is! ClassElement || !element.isPrivate) { 31 | throw InvalidGenerationSourceError( 32 | 'The $ShelfApi annotation can only be used on private classes.', 33 | element: element, 34 | ); 35 | } 36 | 37 | // analyzers 38 | final shelfApi = ShelfApiReader(annotation); 39 | final apiClassAnalyzer = ApiClassAnalyzer(buildStep); 40 | final apiClass = await apiClassAnalyzer.analyzeApiClass(element, shelfApi); 41 | 42 | final library = Library( 43 | (b) => b 44 | ..ignoreForFile.add('type=lint') 45 | ..ignoreForFile.add('invalid_use_of_protected_member') 46 | ..ignoreForFile.add('unused_import') 47 | ..directives.add( 48 | Directive.import('package:shelf_api/builder_utils.dart'), 49 | ) 50 | ..directives.add( 51 | Directive.import( 52 | 'package:shelf_router/shelf_router.dart', 53 | show: const ['RouterParams'], 54 | ), 55 | ) 56 | ..body.add(ApiImplementationBuilder(apiClass)), 57 | ); 58 | 59 | final emitter = DartEmitter.scoped( 60 | orderDirectives: true, 61 | useNullSafetySyntax: true, 62 | ); 63 | 64 | final buffer = StringBuffer(); 65 | library.accept(emitter, buffer); 66 | return buffer.toString(); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/shelf_api/test/unit/riverpod/endpoint_ref_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:riverpod/misc.dart'; 2 | import 'package:riverpod/riverpod.dart'; 3 | import 'package:shelf_api/src/riverpod/endpoint_ref.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | group('requestContext', () { 8 | late ProviderContainer testContainer; 9 | 10 | setUp(() { 11 | testContainer = ProviderContainer.test(); 12 | }); 13 | 14 | test('throws state error by default', () { 15 | expect( 16 | () => testContainer.read(shelfRequestProvider), 17 | throwsA( 18 | isA().having( 19 | (m) => m.exception, 20 | 'exception', 21 | isStateError, 22 | ), 23 | ), 24 | ); 25 | }); 26 | }); 27 | 28 | group('EndpointRef', () { 29 | late ProviderContainer testContainer; 30 | late var callCounter = 0; 31 | final testProvider = Provider((ref) => ++callCounter); 32 | 33 | late EndpointRef sut; 34 | 35 | setUp(() { 36 | callCounter = 0; 37 | testContainer = ProviderContainer.test(); 38 | sut = EndpointRef(testContainer); 39 | }); 40 | 41 | test('exists calls container.exists and returns the result', () { 42 | expect(sut.exists(testProvider), isFalse); 43 | sut.read(testProvider); 44 | expect(sut.exists(testProvider), isTrue); 45 | }); 46 | 47 | test('refresh calls container.refresh and returns the result', () { 48 | final original = sut.read(testProvider); 49 | expect(original, 1); 50 | 51 | final result = sut.refresh(testProvider); 52 | expect(result, 2); 53 | }); 54 | 55 | test('invalidate calls container.invalidate', () { 56 | final original = sut.read(testProvider); 57 | expect(original, 1); 58 | 59 | sut.invalidate(testProvider); 60 | 61 | final result = sut.read(testProvider); 62 | expect(result, 2); 63 | }); 64 | 65 | test( 66 | 'read calls container.listen and returns subscription value', 67 | () async { 68 | final r1 = sut.read(testProvider); 69 | expect(r1, 1); 70 | 71 | await Future.delayed(const Duration(milliseconds: 100)); 72 | 73 | final r2 = sut.read(testProvider); 74 | expect(r2, 1); 75 | }, 76 | ); 77 | }); 78 | } 79 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/test/integration/basic_endpoint_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:test/test.dart'; 4 | 5 | import '../../example/basic_model.dart'; 6 | import 'test_helper.dart'; 7 | 8 | void main() { 9 | late ExampleServer server; 10 | 11 | setUpAll(() async { 12 | server = await ExampleServer.start(); 13 | }); 14 | 15 | tearDownAll(() async { 16 | await server.stop(); 17 | }); 18 | 19 | test('/ endpoint returns data', () async { 20 | final response = await server.apiClient.basicGet(); 21 | expect(response, 'Hello, World!'); 22 | }); 23 | 24 | test('/ endpoint applies global middleware', () async { 25 | final response = await server.apiClient.basicGetRaw(); 26 | expect(response.statusCode, HttpStatus.ok); 27 | expect(response.headers.map, containsPair('X-Api', ['shelf_api'])); 28 | expect(response.headers.map, containsPair('X-Middleware', ['Api'])); 29 | }); 30 | 31 | test('/complex endpoint returns minimal data', () async { 32 | final response = await server.apiClient.basicComplexExampleRaw( 33 | 42, 34 | const BasicModel(11), 35 | factor: 2, 36 | ); 37 | expect(response.statusCode, HttpStatus.created); 38 | expect(response.data, const BasicModel(23)); 39 | expect( 40 | response.headers.map, 41 | containsPair(HttpHeaders.locationHeader, ['/examples/42']), 42 | ); 43 | expect(response.headers.map, isNot(contains('x-extra'))); 44 | expect(response.headers.map, containsPair('X-Api', ['shelf_api'])); 45 | expect(response.headers.map, containsPair('X-Middleware', ['Api'])); 46 | }); 47 | 48 | test('/complex endpoint returns full data', () async { 49 | const testExtra = 'test-extra'; 50 | final response = await server.apiClient.basicComplexExampleRaw( 51 | 25, 52 | const BasicModel(13), 53 | factor: 3, 54 | delta: 0.2, 55 | extra: testExtra, 56 | ); 57 | expect(response.statusCode, HttpStatus.created); 58 | expect(response.data, const BasicModel(39)); 59 | expect( 60 | response.headers.map, 61 | containsPair(HttpHeaders.locationHeader, ['/examples/25']), 62 | ); 63 | expect(response.headers.map, containsPair('x-extra', [testExtra])); 64 | expect(response.headers.map, containsPair('X-Api', ['shelf_api'])); 65 | expect(response.headers.map, containsPair('X-Middleware', ['Api'])); 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /packages/shelf_api/test/integration/test_helper.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print for test files 2 | 3 | import 'dart:async'; 4 | import 'dart:convert'; 5 | import 'dart:io'; 6 | import 'dart:math'; 7 | 8 | import 'package:test/test.dart'; 9 | 10 | class ExampleServer { 11 | final Process _process; 12 | final Uri _baseUri; 13 | final HttpClient _client; 14 | 15 | ExampleServer._(this._process, int port) 16 | : _baseUri = Uri(scheme: 'http', host: 'localhost', port: port, path: '/'), 17 | _client = HttpClient(); 18 | 19 | static Future start() async { 20 | final port = 8000 + Random.secure().nextInt(999); 21 | final process = await Process.start('dart', [ 22 | 'run', 23 | 'example/main.dart', 24 | port.toString(), 25 | ]); 26 | 27 | try { 28 | process.stderr 29 | .transform(utf8.decoder) 30 | .transform(const LineSplitter()) 31 | .listen((l) => print('ERR: $l')); 32 | 33 | final completer = Completer(); 34 | process.stdout 35 | .transform(utf8.decoder) 36 | .transform(const LineSplitter()) 37 | .map((line) { 38 | if (!completer.isCompleted && line.startsWith('Serving at')) { 39 | completer.complete(null); 40 | } 41 | return line; 42 | }) 43 | .listen((l) => print('OUT: $l')); 44 | 45 | await completer.future.timeout(const Duration(seconds: 5)); 46 | 47 | return ExampleServer._(process, port); 48 | } catch (_) { 49 | process.kill(ProcessSignal.sigkill); 50 | rethrow; 51 | } 52 | } 53 | 54 | Future get(Uri url) async { 55 | final request = await _client.getUrl(_baseUri.resolveUri(url)); 56 | final response = await request.close(); 57 | expect(response.statusCode, HttpStatus.ok); 58 | return response.transform(utf8.decoder).join(); 59 | } 60 | 61 | Future getRaw(Uri url, [String? body]) async { 62 | final request = await _client.getUrl(_baseUri.resolveUri(url)); 63 | if (body != null) { 64 | final bytes = utf8.encode(body); 65 | request.headers.add(HttpHeaders.contentLengthHeader, bytes.length); 66 | request.add(bytes); 67 | } 68 | final response = await request.close(); 69 | return response; 70 | } 71 | 72 | Future stop() async { 73 | _client.close(force: true); 74 | 75 | expect(_process.kill(), isTrue); 76 | await _process.exitCode.timeout( 77 | const Duration(seconds: 5), 78 | onTimeout: () { 79 | expect(_process.kill(ProcessSignal.sigkill), isTrue); 80 | return 0; 81 | }, 82 | ); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/client/path_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | import '../../models/api_class.dart'; 5 | import '../../models/endpoint.dart'; 6 | import '../../models/endpoint_method.dart'; 7 | import '../../models/endpoint_path_parameter.dart'; 8 | import '../../models/opaque_constant.dart'; 9 | import '../../util/code/literal_string_builder.dart'; 10 | import '../../util/constants.dart'; 11 | import '../../util/types.dart'; 12 | import '../base/expression_builder.dart'; 13 | 14 | @internal 15 | final class PathBuilder extends ExpressionBuilder { 16 | final ApiClass _apiClass; 17 | final Endpoint _endpoint; 18 | final EndpointMethod _method; 19 | 20 | const PathBuilder(this._apiClass, this._endpoint, this._method); 21 | 22 | @override 23 | Expression build() { 24 | final pathBuilder = LiteralStringBuilder(); 25 | var hasTrailingSlash = false; 26 | 27 | if (_apiClass.basePath case final String path) { 28 | pathBuilder.addLiteral(path); 29 | hasTrailingSlash = path.endsWith('/'); 30 | } 31 | 32 | if (_endpoint.path case final String path) { 33 | pathBuilder.addLiteral(hasTrailingSlash ? path.substring(1) : path); 34 | hasTrailingSlash = path.endsWith('/'); 35 | } 36 | 37 | final methodPath = hasTrailingSlash 38 | ? _method.path.substring(1) 39 | : _method.path; 40 | if (_method.pathParameters.isEmpty) { 41 | pathBuilder.addLiteral(methodPath); 42 | } else { 43 | pathBuilder.addTemplate(methodPath, { 44 | for (final pathParam in _method.pathParameters) 45 | _paramPattern(pathParam): _paramValue(pathParam), 46 | }); 47 | } 48 | 49 | return pathBuilder; 50 | } 51 | 52 | RegExp _paramPattern(EndpointPathParameter pathParam) => 53 | RegExp('<${RegExp.escape(pathParam.name)}(?:\\|.+?)?>'); 54 | 55 | Expression _paramValue(EndpointPathParameter pathParam) { 56 | final paramRef = refer(pathParam.name); 57 | 58 | Expression paramStringRef; 59 | if (pathParam.customToString case final OpaqueConstant customToString) { 60 | paramStringRef = Constants.fromConstant(customToString).call([paramRef]); 61 | } else if (pathParam.isEnum) { 62 | paramStringRef = paramRef.property('name'); 63 | } else if (pathParam.isDateTime) { 64 | paramStringRef = paramRef.property('toIso8601String').call(const []); 65 | } else if (pathParam.urlEncode && !pathParam.isString) { 66 | paramStringRef = paramRef.property('toString').call(const []); 67 | } else { 68 | paramStringRef = paramRef; 69 | } 70 | 71 | if (pathParam.urlEncode) { 72 | return Types.uri.property('encodeComponent').call([paramStringRef]); 73 | } else { 74 | return paramStringRef; 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/analyzers/query_analyzer.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/element/element.dart'; 2 | import 'package:build/build.dart'; 3 | import 'package:meta/meta.dart'; 4 | import 'package:source_gen/source_gen.dart'; 5 | import 'package:source_helper/source_helper.dart'; 6 | 7 | import '../models/endpoint_query_parameter.dart'; 8 | import '../models/opaque_type.dart'; 9 | import '../readers/query_param_reader.dart'; 10 | import '../util/type_checkers.dart'; 11 | 12 | @internal 13 | class QueryAnalyzer { 14 | final BuildStep _buildStep; 15 | 16 | QueryAnalyzer(this._buildStep); 17 | 18 | Future> analyzeQuery(MethodElement method) => 19 | _analyzeQuery(method).toList(); 20 | 21 | Stream _analyzeQuery(MethodElement method) async* { 22 | for (final param in method.formalParameters) { 23 | if (param.isPositional) { 24 | continue; 25 | } 26 | 27 | yield await _analyzeParam(param); 28 | } 29 | } 30 | 31 | Future _analyzeParam( 32 | FormalParameterElement param, 33 | ) async { 34 | var paramType = param.type; 35 | if (paramType.isNullableType && 36 | (param.isRequired || param.hasDefaultValue)) { 37 | throw InvalidGenerationSource( 38 | 'Nullable query parameters can neither be required ' 39 | 'nor have default values', 40 | element: param, 41 | ); 42 | } 43 | 44 | var isList = false; 45 | if (param.type.isDartCoreList) { 46 | if (param.type.isNullableType) { 47 | throw InvalidGenerationSource( 48 | 'Query parameters for list values cannot be nullable lists!', 49 | todo: 'Make list param non nullable.', 50 | element: param, 51 | ); 52 | } 53 | 54 | [paramType] = paramType.typeArgumentsOf(TypeCheckers.list)!; 55 | if (paramType.isNullableType) { 56 | throw InvalidGenerationSource( 57 | 'Query parameters for list values cannot have nullable list values!', 58 | todo: 'Make list param type $paramType non nullable.', 59 | element: param, 60 | ); 61 | } 62 | 63 | isList = true; 64 | } 65 | 66 | final queryParam = param.queryParamAnnotation; 67 | return EndpointQueryParameter( 68 | paramName: param.name!, 69 | queryName: queryParam?.name ?? param.name!, 70 | type: OpaqueDartType(_buildStep, paramType), 71 | isString: paramType.isDartCoreString, 72 | isEnum: paramType.isEnum, 73 | isDateTime: TypeCheckers.dateTime.isExactlyType(paramType), 74 | isList: isList, 75 | isOptional: param.isOptional, 76 | defaultValue: param.defaultValueCode, 77 | customParse: await queryParam?.parse(_buildStep), 78 | customToString: await queryParam?.stringify(_buildStep), 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/util/code/literal_string_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | @internal 5 | class LiteralStringBuilder extends Expression { 6 | final _parts = <_StringInfo>[]; 7 | 8 | void addLiteral(String string) => _parts.add(_LiteralStringInfo(string)); 9 | 10 | void addTemplate(String template, Map values) => 11 | _parts.add(_TemplateStringInfo(template, values)); 12 | 13 | @override 14 | R accept(covariant ExpressionVisitor visitor, [R? context]) { 15 | var currentContext = context; 16 | for (final code in _build()) { 17 | currentContext = visitor.visitCodeExpression( 18 | CodeExpression(code), 19 | context, 20 | ); 21 | } 22 | return currentContext!; 23 | } 24 | 25 | Iterable _build() sync* { 26 | yield const Code("'"); 27 | for (final part in _parts) { 28 | yield* part.stringCode; 29 | } 30 | yield const Code("'"); 31 | } 32 | } 33 | 34 | sealed class _StringInfo { 35 | final _dollarQuoteRegexp = RegExp(r"""(?=[$'\\])"""); 36 | 37 | Iterable get stringCode; 38 | 39 | String _escaped(String string) => string.replaceAll(_dollarQuoteRegexp, r'\'); 40 | } 41 | 42 | class _LiteralStringInfo extends _StringInfo { 43 | final String string; 44 | 45 | _LiteralStringInfo(this.string); 46 | 47 | @override 48 | Iterable get stringCode sync* { 49 | yield Code(_escaped(string)); 50 | } 51 | } 52 | 53 | class _TemplateStringInfo extends _StringInfo { 54 | final String template; 55 | final Map values; 56 | 57 | _TemplateStringInfo(this.template, this.values); 58 | 59 | @override 60 | Iterable get stringCode sync* { 61 | final replacements = <(int, int, Expression)>[]; 62 | for (final MapEntry(key: pattern, value: value) in values.entries) { 63 | final matches = pattern.allMatches(template); 64 | for (final match in matches) { 65 | replacements.add((match.start, match.end, value)); 66 | } 67 | } 68 | 69 | replacements.sort((a, b) { 70 | final startCmp = a.$1.compareTo(b.$1); 71 | return startCmp != 0 ? startCmp : a.$2.compareTo(b.$2); 72 | }); 73 | 74 | var previousEnd = 0; 75 | for (final (start, end, value) in replacements) { 76 | if (start < previousEnd) { 77 | throw StateError('Cannot have replacement patterns that overlap!'); 78 | } 79 | 80 | if (previousEnd != start) { 81 | yield Code(_escaped(template.substring(previousEnd, start))); 82 | } 83 | yield const Code(r'${'); 84 | yield value.code; 85 | yield const Code('}'); 86 | previousEnd = end; 87 | } 88 | 89 | if (previousEnd < template.length) { 90 | yield Code(_escaped(template.substring(previousEnd))); 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/client/query_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | import '../../models/endpoint_query_parameter.dart'; 5 | import '../../models/opaque_constant.dart'; 6 | import '../../util/constants.dart'; 7 | import '../../util/types.dart'; 8 | import '../base/expression_builder.dart'; 9 | 10 | @internal 11 | final class QueryBuilder extends ExpressionBuilder { 12 | static const _valueRef = Reference(r'$value'); 13 | 14 | final List _queryParams; 15 | 16 | const QueryBuilder(this._queryParams); 17 | 18 | bool get hasParams => _queryParams.isNotEmpty; 19 | 20 | @override 21 | Expression build() => literalMap( 22 | Map.fromEntries(_buildEntries()), 23 | Types.string, 24 | Types.dynamic$, 25 | ); 26 | 27 | Iterable> _buildEntries() sync* { 28 | for (final queryParam in _queryParams) { 29 | final paramRef = refer(queryParam.paramName); 30 | final key = _ifNotNullKey( 31 | queryParam, 32 | literalString(queryParam.queryName, raw: true), 33 | paramRef, 34 | ); 35 | 36 | Expression value; 37 | if (queryParam.customToString case final OpaqueConstant customToString) { 38 | value = Constants.fromConstant(customToString).call([paramRef]); 39 | } else if (queryParam.isString) { 40 | value = paramRef; 41 | } else if (queryParam.isList) { 42 | value = paramRef 43 | .property('map') 44 | .call([ 45 | Method( 46 | (b) => b 47 | ..requiredParameters.add( 48 | Parameter((b) => b..name = _valueRef.symbol!), 49 | ) 50 | ..body = _convertToString(queryParam, _valueRef).code, 51 | ).closure, 52 | ]) 53 | .property('toList') 54 | .call(const []); 55 | } else { 56 | value = _convertToString(queryParam, paramRef); 57 | } 58 | 59 | yield MapEntry(key, value); 60 | } 61 | } 62 | 63 | Expression _ifNotNullKey( 64 | EndpointQueryParameter param, 65 | Expression key, 66 | Expression value, 67 | ) { 68 | if (!param.isOptional) { 69 | return key; 70 | } 71 | 72 | return CodeExpression( 73 | Block.of([ 74 | const Code('if('), 75 | value.notEqualTo(literalNull).code, 76 | const Code(')'), 77 | key.code, 78 | ]), 79 | ); 80 | } 81 | 82 | Expression _convertToString(EndpointQueryParameter param, Expression value) { 83 | if (param.isEnum) { 84 | return value.property('name'); 85 | } else if (param.isDateTime) { 86 | return value.property('toIso8601String').call(const []); 87 | } else { 88 | return value.property('toString').call(const []); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/example/endpoints/params_endpoint.dart: -------------------------------------------------------------------------------- 1 | import 'package:shelf_api/shelf_api.dart'; 2 | 3 | import '../basic_enum.dart'; 4 | 5 | @ApiEndpoint('/params') 6 | class ParamsEndpoint extends ShelfEndpoint { 7 | ParamsEndpoint(super.request); 8 | 9 | @Get(r'/path/simple//sub//') 10 | List getPathSimple(String p1, int p2, BasicEnum p3) => [ 11 | p1, 12 | p2, 13 | p3.toString(), 14 | ]; 15 | 16 | @Get(r'/path/custom//sub/') 17 | List getPathCustom( 18 | @PathParam(parse: parseString, stringify: stringifyString) String c1, 19 | @PathParam(urlEncode: false) Uri c2, 20 | ) => [c1, c2.toString()]; 21 | 22 | @Get('/query') 23 | Map getQuery({ 24 | required String sValue, 25 | int? oValue, 26 | double dValue = 42.0, 27 | required Uri uValue, 28 | DateTime? dtValue, 29 | String s2Value = 's2', 30 | required BasicEnum eValue, 31 | }) => { 32 | 'sValue': sValue, 33 | 'oValue': oValue, 34 | 'dValue': dValue, 35 | 'uValue': uValue.toString(), 36 | 'dtValue': dtValue?.toIso8601String(), 37 | 's2Value': s2Value, 38 | 'eValue': eValue.toString(), 39 | }; 40 | 41 | @Get('/query/list') 42 | Map getQueryList({ 43 | required List sValue, 44 | required List uValue, 45 | List iValue = const [1, 2, 3], 46 | List dtValue = const [], 47 | List s2Value = const ['s2'], 48 | List eValue = const [], 49 | }) => { 50 | 'sValue': sValue, 51 | 'uValue': uValue.toString(), 52 | 'iValue': iValue, 53 | 'dtValue': dtValue.toString(), 54 | 's2Value': s2Value, 55 | 'eValue': eValue.toString(), 56 | }; 57 | 58 | @Get('/query/custom') 59 | Map getQueryCustom({ 60 | @QueryParam(name: 'named_value') required String namedValue, 61 | @QueryParam(parse: parseString, stringify: stringifyString) 62 | required String parsedValue, 63 | @QueryParam( 64 | name: 'list_value', 65 | parse: parseStringList, 66 | stringify: stringifyStringList, 67 | ) 68 | List parsedListValue = const ['unparsed', 'values'], 69 | }) => { 70 | 'namedValue': namedValue, 71 | 'parsedValue': parsedValue, 72 | 'parsedListValue': parsedListValue, 73 | }; 74 | 75 | @Get('/combined/') 76 | Map getCombined( 77 | DateTime p1, { 78 | required int precision, 79 | bool roundDown = false, 80 | }) => { 81 | 'p1': p1.microsecondsSinceEpoch, 82 | 'precision': precision, 83 | 'roundDown': roundDown, 84 | }; 85 | 86 | static String parseString(String s) => s * 3; 87 | 88 | static String stringifyString(String s) => s.toUpperCase(); 89 | 90 | static List parseStringList(List s) => 91 | s.map(parseString).toList(); 92 | 93 | static List stringifyStringList(List s) => 94 | s.map(stringifyString).toList(); 95 | } 96 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/src/riverpod/endpoint_ref.dart: -------------------------------------------------------------------------------- 1 | import 'package:meta/meta.dart'; 2 | import 'package:riverpod/misc.dart'; 3 | import 'package:riverpod/riverpod.dart'; 4 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 5 | import 'package:shelf/shelf.dart'; 6 | 7 | part 'endpoint_ref.g.dart'; 8 | 9 | /// A provider to access a requests [Request] within a provider. 10 | /// 11 | /// **Important:** This provider must be added as a dependency, as it will 12 | /// cause dependant providers to the valid only for the request itself. This 13 | /// means providers depending on [shelfRequestProvider] will be unique per 14 | /// request instead of being a global singleton. 15 | /// 16 | /// This can also be used to create request unique providers, even if the 17 | /// [Request] itself is not being used: 18 | /// 19 | /// ```dart 20 | /// @Riverpod(dependencies: [shelfRequestProvider]) 21 | /// MyValue myValue(MyValueRef ref) => MyValue(ref); 22 | /// ``` 23 | @Riverpod(keepAlive: true, dependencies: []) 24 | Request shelfRequest(Ref ref) => throw StateError( 25 | 'shelfRequestProvider can only be accessed via session.ref', 26 | ); 27 | 28 | /// An object that allows shelf request handlers to interact with providers. 29 | class EndpointRef { 30 | /// @nodoc 31 | @internal 32 | final ProviderContainer container; 33 | 34 | final _keepAliveSubs = , ProviderSubscription>{}; 35 | 36 | /// @nodoc 37 | @internal 38 | EndpointRef(this.container); 39 | 40 | /// Determines whether a provider is initialized or not. 41 | /// 42 | /// See [Ref.exists] for more details. 43 | bool exists(ProviderBase provider) => container.exists(provider); 44 | 45 | /// Reads a provider without listening to it. 46 | /// 47 | /// Works just like [Ref.read], but with one minor difference: If the provider 48 | /// is a auto disposable provider, it will not be disposed until the request 49 | /// itself is finished. Internally, [Ref.listen] is used to archive this. 50 | /// 51 | /// See [Ref.read] and [Ref.watch] for more details. 52 | T read(ProviderListenable provider) { 53 | final subscription = 54 | _keepAliveSubs.putIfAbsent( 55 | provider, 56 | () => container.listen(provider, (_, _) {}), 57 | ) 58 | as ProviderSubscription; 59 | return subscription.read(); 60 | } 61 | 62 | /// Forces a provider to re-evaluate its state immediately, and return the 63 | /// created value. 64 | /// 65 | /// See [Ref.refresh] for more details. 66 | @useResult 67 | State refresh(Refreshable provider) => 68 | container.refresh(provider); 69 | 70 | /// Invalidates the state of the provider, causing it to refresh. 71 | /// 72 | /// See [Ref.invalidate] for more details. 73 | void invalidate(ProviderOrFamily provider) => container.invalidate(provider); 74 | 75 | /// @nodoc 76 | @internal 77 | void dispose() { 78 | for (final subscription in _keepAliveSubs.values) { 79 | subscription.close(); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/shelf_api/test/unit/util/stream_extensions_test.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: discarded_futures for test files 2 | 3 | import 'dart:async'; 4 | import 'dart:io'; 5 | 6 | import 'package:shelf/shelf.dart'; 7 | import 'package:shelf_api/src/api/http_method.dart'; 8 | import 'package:shelf_api/src/util/stream_extensions.dart'; 9 | import 'package:test/test.dart'; 10 | 11 | void main() { 12 | group('ShelfApiStreamX', () { 13 | test('registers callback that is invoked when stream is done', () { 14 | final testStream = Stream.fromIterable([1, 2, 3]); 15 | 16 | final sut = testStream.onFinished(expectAsync0(() {})); 17 | 18 | expect(sut, emitsInOrder([1, 2, 3, emitsDone])); 19 | }); 20 | 21 | test('registers callback that is invoked when stream is canceled', () { 22 | final testController = StreamController(); 23 | addTearDown(testController.close); 24 | 25 | final sut = testController.stream.onFinished(expectAsync0(() {})); 26 | late StreamSubscription sub; 27 | sub = sut.listen( 28 | expectAsync1(count: 2, (event) { 29 | if (event == 2) { 30 | sub.cancel(); 31 | } 32 | }), 33 | onDone: () => fail('onDone should not be called'), 34 | ); 35 | 36 | testController 37 | ..add(1) 38 | ..add(2); 39 | }); 40 | }); 41 | 42 | group('ShelfApiByteStreamX', () { 43 | group('collect', () { 44 | group('(without request)', () { 45 | test('merges stream into single byte array', () async { 46 | final testStream = Stream.fromIterable([ 47 | [1, 2, 3], 48 | [4], 49 | [5, 6], 50 | ]); 51 | 52 | final result = await testStream.collect(); 53 | expect(result, [1, 2, 3, 4, 5, 6]); 54 | }); 55 | }); 56 | 57 | group('(with request)', () { 58 | final request = Request( 59 | HttpMethod.get, 60 | Uri.http('localhost', '/'), 61 | headers: {HttpHeaders.contentLengthHeader: '6'}, 62 | ); 63 | 64 | test('merges stream into single byte array', () async { 65 | final testStream = Stream.fromIterable([ 66 | [1, 2, 3], 67 | [4], 68 | [5, 6], 69 | ]); 70 | 71 | final result = await testStream.collect(request); 72 | expect(result, [1, 2, 3, 4, 5, 6]); 73 | }); 74 | 75 | test('truncates data if data is more than content length', () async { 76 | final testStream = Stream.fromIterable([ 77 | [1, 2], 78 | [3, 4, 5], 79 | [6, 7], 80 | ]); 81 | 82 | final result = await testStream.collect(request); 83 | expect(result, [1, 2, 3, 4, 5, 6]); 84 | }); 85 | 86 | test('returns zero-initialized oversized array', () async { 87 | final testStream = Stream.fromIterable([ 88 | [1, 2, 3], 89 | [4], 90 | ]); 91 | 92 | final result = await testStream.collect(request); 93 | expect(result, [1, 2, 3, 4, 0, 0]); 94 | }); 95 | }); 96 | }); 97 | }); 98 | } 99 | -------------------------------------------------------------------------------- /packages/shelf_api/test/integration/riverpod_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:test/test.dart'; 2 | 3 | import 'test_helper.dart'; 4 | 5 | enum _Mode { singleton, factory, requestSingleton, requestFactory } 6 | 7 | void main() { 8 | late ExampleServer server; 9 | 10 | setUpAll(() async { 11 | server = await ExampleServer.start(); 12 | }); 13 | 14 | tearDownAll(() async { 15 | await server.stop(); 16 | }); 17 | 18 | Future getRiverpod(_Mode mode, [Duration? delay]) async { 19 | final timestamp = await server.get( 20 | Uri( 21 | path: '/riverpod', 22 | queryParameters: { 23 | 'mode': mode.name, 24 | if (delay != null) 'delay': delay.inMilliseconds.toString(), 25 | }, 26 | ), 27 | ); 28 | return DateTime.parse(timestamp); 29 | } 30 | 31 | test('singleton returns constant timestamp value', () async { 32 | final firstTimestamp = await getRiverpod(_Mode.singleton); 33 | await Future.delayed(const Duration(milliseconds: 100)); 34 | final secondTimestamp = await getRiverpod(_Mode.singleton); 35 | 36 | expect(secondTimestamp, firstTimestamp); 37 | }); 38 | 39 | group('factory', () { 40 | test('returns new timestamp for every request', () async { 41 | final firstTimestamp = await getRiverpod(_Mode.factory); 42 | await Future.delayed(const Duration(milliseconds: 100)); 43 | final secondTimestamp = await getRiverpod(_Mode.factory); 44 | 45 | expect(secondTimestamp, isNot(firstTimestamp)); 46 | }); 47 | 48 | test('returns same timestamp if requests are run in parallel', () async { 49 | final [ 50 | firstTimestamp, 51 | secondTimestamp, 52 | thirdTimestamp, 53 | ] = await Future.wait([ 54 | getRiverpod(_Mode.factory, const Duration(milliseconds: 200)), 55 | Future.delayed( 56 | const Duration(milliseconds: 100), 57 | () => getRiverpod(_Mode.factory, const Duration(milliseconds: 300)), 58 | ), 59 | Future.delayed( 60 | const Duration(milliseconds: 300), 61 | () => getRiverpod(_Mode.factory), 62 | ), 63 | ]); 64 | 65 | expect(secondTimestamp, firstTimestamp); 66 | expect(thirdTimestamp, firstTimestamp); 67 | }); 68 | }); 69 | 70 | test('requestSingleton returns new timestamp for every request', () async { 71 | final [firstTimestamp, secondTimestamp] = await Future.wait([ 72 | getRiverpod(_Mode.requestSingleton, const Duration(milliseconds: 200)), 73 | Future.delayed( 74 | const Duration(milliseconds: 100), 75 | () => getRiverpod(_Mode.requestSingleton), 76 | ), 77 | ]); 78 | 79 | expect(secondTimestamp, isNot(firstTimestamp)); 80 | }); 81 | 82 | test('requestFactory returns new timestamp for every request', () async { 83 | final [firstTimestamp, secondTimestamp] = await Future.wait([ 84 | getRiverpod(_Mode.requestFactory, const Duration(milliseconds: 200)), 85 | Future.delayed( 86 | const Duration(milliseconds: 100), 87 | () => getRiverpod(_Mode.requestFactory), 88 | ), 89 | ]); 90 | 91 | expect(secondTimestamp, isNot(firstTimestamp)); 92 | }); 93 | } 94 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/api/api_handler_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | import '../../models/endpoint.dart'; 5 | import '../../models/endpoint_method.dart'; 6 | import '../../util/code/try.dart'; 7 | import '../../util/types.dart'; 8 | import '../base/spec_builder.dart'; 9 | import 'body_builder.dart'; 10 | import 'path_builder.dart'; 11 | import 'query_builder.dart'; 12 | import 'response_builder.dart'; 13 | 14 | @internal 15 | final class ApiHandlerBuilder extends SpecBuilder { 16 | static const _endpointRef = Reference(r'$endpoint'); 17 | static const _requestRef = Reference(r'$request'); 18 | 19 | final Endpoint _endpoint; 20 | final EndpointMethod _method; 21 | 22 | static String handlerMethodName(Endpoint endpoint, EndpointMethod method) => 23 | '_handler\$${endpoint.name}\$${method.name}'; 24 | 25 | const ApiHandlerBuilder(this._endpoint, this._method); 26 | 27 | @override 28 | Method build() => Method( 29 | (b) => b 30 | ..name = handlerMethodName(_endpoint, _method) 31 | ..returns = Types.future(Types.shelfResponse) 32 | ..modifier = MethodModifier.async 33 | ..requiredParameters.addAll(_buildParameters()) 34 | ..body = Block.of(_buildBody()), 35 | ); 36 | 37 | Iterable _buildParameters() sync* { 38 | yield Parameter( 39 | (b) => b 40 | ..name = _requestRef.symbol! 41 | ..type = Types.shelfRequest, 42 | ); 43 | 44 | for (final pathParam in _method.pathParameters) { 45 | yield Parameter( 46 | (b) => b 47 | ..name = pathParam.handlerParamName 48 | ..type = Types.string, 49 | ); 50 | } 51 | } 52 | 53 | Iterable _buildBody() sync* { 54 | yield declareFinal(_endpointRef.symbol!) 55 | .assign( 56 | Types.fromType(_endpoint.endpointType).newInstance([_requestRef]), 57 | ) 58 | .statement; 59 | 60 | yield _endpointRef.property('init').call(const []).awaited.statement; 61 | if (_method.isStream) { 62 | yield* _buildTryBody(); 63 | } else { 64 | yield Try(Block.of(_buildTryBody())) 65 | ..finallyBody = _endpointRef 66 | .property('dispose') 67 | .call(const []) 68 | .awaited 69 | .statement; 70 | } 71 | } 72 | 73 | Iterable _buildTryBody() sync* { 74 | final bodyBuilder = BodyBuilder(_method.body, _requestRef); 75 | final pathBuilder = PathBuilder(_method.pathParameters); 76 | final queryBuilder = QueryBuilder(_method.queryParameters, _requestRef); 77 | 78 | yield bodyBuilder.variables; 79 | yield queryBuilder.variables; 80 | 81 | var invocation = _endpointRef.property(_method.name).call([ 82 | ...pathBuilder.build(), 83 | if (bodyBuilder.parameter case final Expression param) param, 84 | ], queryBuilder.parameters); 85 | if (_method.isAsync) { 86 | invocation = invocation.awaited; 87 | } else if (_method.isStream) { 88 | invocation = invocation.property('onFinished').call([ 89 | _endpointRef.property('dispose'), 90 | ]); 91 | } 92 | yield ResponseBuilder(_method.response, invocation); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/example/endpoints/response_endpoint.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:shelf/shelf.dart'; 5 | import 'package:shelf_api/shelf_api.dart'; 6 | 7 | import '../basic_model.dart'; 8 | 9 | @ApiEndpoint('/response/') 10 | class ResponseEndpoint extends ShelfEndpoint { 11 | ResponseEndpoint(super.request); 12 | 13 | @Get('/noContent') 14 | void noContent() {} 15 | 16 | @Get('/text') 17 | String text() => 'Hello, World!'; 18 | 19 | @Get('/binary') 20 | Uint8List binary() => Uint8List.fromList([1, 2, 3, 4, 5]); 21 | 22 | @Get('/json') 23 | BasicModel json() => const BasicModel(42); 24 | 25 | @Get('/json/list') 26 | List jsonList() => const [1, 2, 3]; 27 | 28 | @Get('/json/map') 29 | Map jsonMap() => const { 30 | 'a': BasicModel(1), 31 | 'b': BasicModel(2), 32 | }; 33 | 34 | @Get( 35 | '/json/custom', 36 | fromJson: BasicModel.fromJsonX, 37 | toJson: BasicModel.toJsonX, 38 | ) 39 | BasicModel jsonCustom() => const BasicModel(24); 40 | 41 | @Get('/response') 42 | Response response() => Response( 43 | HttpStatus.accepted, 44 | body: 'Hello, World!', 45 | headers: const {'X-Extra-Data': 'Extra Header Data'}, 46 | ); 47 | 48 | @Get('/response/typed') 49 | TResponse typedResponse() => TResponse.ok( 50 | 'Hello, World!', 51 | headers: const {'X-Extra-Data': 'Extra Header Data'}, 52 | ); 53 | 54 | @Get('/async/noContent') 55 | Future asyncNoContent() async {} 56 | 57 | @Get('/async/text') 58 | Future asyncText() async => 'Hello, World!'; 59 | 60 | @Get('/async/binary') 61 | Future asyncBinary() async => Uint8List.fromList([1, 2, 3, 4, 5]); 62 | 63 | @Get('/async/json') 64 | Future asyncJson({bool asNull = false}) async => asNull ? null : 4224; 65 | 66 | @Get('/async/json/list') 67 | Future?> asyncJsonList({bool asNull = false}) async => 68 | asNull ? null : const [BasicModel(1), BasicModel(2), BasicModel(3)]; 69 | 70 | @Get('/async/json/map') 71 | Future?> asyncJsonMap({bool asNull = false}) async => 72 | asNull ? null : const {'a': 1, 'b': 2}; 73 | 74 | @Get( 75 | '/async/json/custom', 76 | fromJson: BasicModel.fromJsonX, 77 | toJson: BasicModel.toJsonX, 78 | ) 79 | Future asyncJsonCustom({bool asNull = false}) async => 80 | asNull ? null : const BasicModel(42); 81 | 82 | @Get('/async/response') 83 | Future asyncResponse({bool asNull = false}) async => Response( 84 | asNull ? HttpStatus.noContent : HttpStatus.ok, 85 | body: asNull ? null : 'Hello, World!', 86 | headers: {'X-As-Null': asNull.toString()}, 87 | ); 88 | 89 | @Get('/async/response/typed') 90 | Future> asyncTypedResponse({ 91 | bool asNull = false, 92 | }) async => TResponse( 93 | asNull ? HttpStatus.noContent : HttpStatus.ok, 94 | body: asNull ? null : const BasicModel(11), 95 | headers: {'X-As-Null': asNull.toString()}, 96 | ); 97 | 98 | @Get('/stream/text') 99 | Stream streamText() => Stream.value('Hello, World!'); 100 | 101 | @Get('/stream/binary') 102 | Stream> streamBinary() => Stream.value([1, 2, 3, 4, 5]); 103 | } 104 | -------------------------------------------------------------------------------- /packages/shelf_api/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.4.1] - 2025-12-10 8 | ### Changed 9 | - Updated min sdk version to ^3.10.0 10 | - Updated dependencies 11 | 12 | ## [1.4.0] - 2025-10-03 13 | ### Changed 14 | - Updated min sdk version to ^3.9.0 15 | - Updated dependencies 16 | - Updated riverpod to 3.x 17 | 18 | ## [1.3.3] - 2025-07-30 19 | ### Changed 20 | - Updated min sdk version to ^3.8.0 21 | - Updated dependencies 22 | 23 | ## [1.3.2] - 2025-03-16 24 | ### Changed 25 | - Updated dependencies 26 | - Updated min dart sdk to 3.7.0 27 | 28 | ## [1.3.1] - 2024-12-31 29 | ### Changed 30 | - Updated dependencies 31 | - Updated min dart sdk to 3.6.0 32 | 33 | ## [1.3.0+1] - 2024-11-28 34 | ### Changed 35 | - Updated dependencies 36 | 37 | ## [1.3.0] - 2024-09-08 38 | ### Added 39 | - Added `urlEncode` to `PathParam` 40 | - Ensures that all path parameters are correctly encoded by the api client and 41 | decoded by the server. 42 | 43 | ## [1.2.2] - 2024-08-29 44 | ### Changed 45 | - Updated dependencies 46 | - Updated min dart sdk to 3.5.0 47 | 48 | ## [1.2.1] - 2024-06-17 49 | ### Changed 50 | - Updated dependencies 51 | 52 | ### Fixed 53 | - Ensure request provider is always up to date with the newest request 54 | 55 | ## [1.2.0] - 2024-05-29 56 | ### Added 57 | - Added middleware support to `ShelfApi` and `ApiEndpoint` annotations 58 | 59 | ## [1.1.0] - 2024-05-16 60 | ### Added 61 | - Added rivershelfContainer middleware 62 | 63 | ## [1.0.2] - 2024-05-15 64 | ### Added 65 | - Added content types to body annotation 66 | 67 | ### Changed 68 | - Remove dependencies to dart:io to make the package web compatible 69 | 70 | ## [1.0.1] - 2024-05-13 71 | ### Changed 72 | - rename requestProvider to shelfRequestProvider 73 | 74 | ## [1.0.0+1] - 2024-05-13 75 | ### Added 76 | - Initial Release 77 | 78 | [1.4.1]: https://github.com/Skycoder42/shelf_api/compare/shelf_api-v1.4.0...shelf_api-v1.4.1 79 | [1.4.0]: https://github.com/Skycoder42/shelf_api/compare/shelf_api-v1.3.3...shelf_api-v1.4.0 80 | [1.3.3]: https://github.com/Skycoder42/shelf_api/compare/shelf_api-v1.3.2...shelf_api-v1.3.3 81 | [1.3.2]: https://github.com/Skycoder42/shelf_api/compare/shelf_api-v1.3.1...shelf_api-v1.3.2 82 | [1.3.1]: https://github.com/Skycoder42/shelf_api/compare/shelf_api-v1.3.0+1...shelf_api-v1.3.1 83 | [1.3.0+1]: https://github.com/Skycoder42/shelf_api/compare/shelf_api-v1.3.0...shelf_api-v1.3.0+1 84 | [1.3.0]: https://github.com/Skycoder42/shelf_api/compare/shelf_api-v1.2.2...shelf_api-v1.3.0 85 | [1.2.2]: https://github.com/Skycoder42/shelf_api/compare/shelf_api-v1.2.1...shelf_api-v1.2.2 86 | [1.2.1]: https://github.com/Skycoder42/shelf_api/compare/shelf_api-v1.2.0...shelf_api-v1.2.1 87 | [1.2.0]: https://github.com/Skycoder42/shelf_api/compare/shelf_api-v1.1.0...shelf_api-v1.2.0 88 | [1.1.0]: https://github.com/Skycoder42/shelf_api/compare/shelf_api-v1.0.2...shelf_api-v1.1.0 89 | [1.0.2]: https://github.com/Skycoder42/shelf_api/compare/shelf_api-v1.0.1...shelf_api-v1.0.2 90 | [1.0.1]: https://github.com/Skycoder42/shelf_api/compare/shelf_api-v1.0.0+1...shelf_api-v1.0.1 91 | [1.0.0+1]: https://github.com/Skycoder42/shelf_api/releases/tag/shelf_api-v1.0.0+1 92 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.3.1] - 2025-12-10 8 | ### Changed 9 | - Updated min sdk version to ^3.10.0 10 | - Updated dependencies 11 | 12 | ## [1.3.0] - 2025-10-03 13 | ### Changed 14 | - Updated min sdk version to ^3.9.0 15 | - Updated dependencies 16 | - Update build to 3.x 17 | - source\_gen to 3.x 18 | - Widen dependency constraints 19 | - analyzer to allow both 7.6.x and 8.x 20 | - build to allow both 3.1.x and 4.x 21 | - source\_gen to allow both 3.1.x and 4.x 22 | 23 | ## [1.2.3] - 2025-07-30 24 | ### Changed 25 | - Updated min sdk version to ^3.8.0 26 | - Updated dependencies 27 | 28 | ## [1.2.2] - 2025-03-16 29 | ### Changed 30 | - Updated dependencies 31 | - Updated min dart sdk to 3.7.0 32 | - Updated min `shelf_api` to 1.3.2 33 | 34 | ## [1.2.1] - 2024-12-31 35 | ### Changed 36 | - Updated dependencies 37 | - Updated min dart sdk to 3.6.0 38 | - Updated min `shelf_api` to 1.3.1 39 | 40 | ## [1.2.0+1] - 2024-11-28 41 | ### Changed 42 | - Updated dependencies 43 | 44 | ## [1.2.0] - 2024-09-08 45 | ### Added 46 | - Added support for automatic handling of enums as path and query parameters 47 | - Are converter to value strings via `enum.name` and parsed using 48 | `enum.values.byName` 49 | - Added support for URL encoding of path parameters 50 | - Ensures that all path parameters are correctly encoded by the api client and 51 | decoded by the server 52 | - Enabled by default, can be turned of via `PathParam.urlEncode` annotation 53 | 54 | ### Changed 55 | - Updated min `shelf_api` to 1.3.0 56 | 57 | ## [1.1.1] - 2024-08-29 58 | ### Changed 59 | - Updated dependencies 60 | - Updated min dart sdk to 3.5.0 61 | 62 | ## [1.1.0] - 2024-05-29 63 | ### Added 64 | - Added middleware support 65 | 66 | ## [1.0.1] - 2024-05-16 67 | ### Fixed 68 | - Add missing linter ignore 69 | - Add missing close method to client 70 | 71 | ## [1.0.0+1] - 2024-05-15 72 | ### Added 73 | - Initial release 74 | 75 | [1.3.1]: https://github.com/Skycoder42/shelf_api/compare/shelf_api_builder-v1.3.0...shelf_api_builder-v1.3.1 76 | [1.3.0]: https://github.com/Skycoder42/shelf_api/compare/shelf_api_builder-v1.2.3...shelf_api_builder-v1.3.0 77 | [1.2.3]: https://github.com/Skycoder42/shelf_api/compare/shelf_api_builder-v1.2.2...shelf_api_builder-v1.2.3 78 | [1.2.2]: https://github.com/Skycoder42/shelf_api/compare/shelf_api_builder-v1.2.1...shelf_api_builder-v1.2.2 79 | [1.2.1]: https://github.com/Skycoder42/shelf_api/compare/shelf_api_builder-v1.2.0+1...shelf_api_builder-v1.2.1 80 | [1.2.0+1]: https://github.com/Skycoder42/shelf_api/compare/shelf_api_builder-v1.2.0...shelf_api_builder-v1.2.0+1 81 | [1.2.0]: https://github.com/Skycoder42/shelf_api/compare/shelf_api_builder-v1.1.1...shelf_api_builder-v1.2.0 82 | [1.1.1]: https://github.com/Skycoder42/shelf_api/compare/shelf_api_builder-v1.1.0...shelf_api_builder-v1.1.1 83 | [1.1.0]: https://github.com/Skycoder42/shelf_api/compare/shelf_api_builder-v1.0.1...shelf_api_builder-v1.1.0 84 | [1.0.1]: https://github.com/Skycoder42/shelf_api/compare/shelf_api_builder-v1.0.0+1...shelf_api_builder-v1.0.1 85 | [1.0.0+1]: https://github.com/Skycoder42/shelf_api/releases/tag/shelf_api_builder-v1.0.0+1 86 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/api/response_builder.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:code_builder/code_builder.dart'; 4 | import 'package:meta/meta.dart'; 5 | 6 | import '../../models/endpoint_response.dart'; 7 | import '../../models/opaque_constant.dart'; 8 | import '../../util/constants.dart'; 9 | import '../../util/types.dart'; 10 | import '../base/code_builder.dart'; 11 | 12 | @internal 13 | final class ResponseBuilder extends CodeBuilder { 14 | static const _responseRef = Reference(r'$response'); 15 | 16 | final EndpointResponse _response; 17 | final Expression _invocation; 18 | 19 | const ResponseBuilder(this._response, this._invocation); 20 | 21 | @override 22 | Iterable build() sync* { 23 | if (_response.isResponse) { 24 | yield _invocation.returned.statement; 25 | return; 26 | } 27 | 28 | switch (_response.responseType) { 29 | case EndpointResponseType.noContent: 30 | yield _invocation.statement; 31 | yield Types.shelfResponse 32 | .newInstance([literalNum(HttpStatus.noContent)]) 33 | .returned 34 | .statement; 35 | case EndpointResponseType.text: 36 | yield Types.shelfResponse 37 | .newInstanceNamed('ok', [_invocation], _extraParams('text')) 38 | .returned 39 | .statement; 40 | case EndpointResponseType.binary: 41 | yield Types.shelfResponse 42 | .newInstanceNamed('ok', [_invocation], _extraParams('binary')) 43 | .returned 44 | .statement; 45 | case EndpointResponseType.textStream: 46 | yield Types.shelfResponse 47 | .newInstanceNamed('ok', [ 48 | _invocation.property('transform').call([ 49 | Constants.utf8.property('encoder'), 50 | ]), 51 | ], _extraParams('text', Constants.utf8)) 52 | .returned 53 | .statement; 54 | case EndpointResponseType.binaryStream: 55 | yield Types.shelfResponse 56 | .newInstanceNamed('ok', [_invocation], _extraParams('binary')) 57 | .returned 58 | .statement; 59 | case EndpointResponseType.json: 60 | yield* _buildJson(); 61 | case EndpointResponseType.dynamic: 62 | throw StateError( 63 | 'dynamic response type can only occur for response returns!', 64 | ); 65 | } 66 | } 67 | 68 | Iterable _buildJson() sync* { 69 | final serializableType = _response.serializableReturnType; 70 | 71 | Expression responseExpr; 72 | if (serializableType.toJson case final OpaqueConstant toJson) { 73 | if (serializableType.isNullable) { 74 | yield declareFinal(_responseRef.symbol!).assign(_invocation).statement; 75 | responseExpr = _responseRef 76 | .notEqualTo(literalNull) 77 | .conditional( 78 | Constants.fromConstant(toJson).call([_responseRef]), 79 | literalNull, 80 | ); 81 | } else { 82 | responseExpr = Constants.fromConstant(toJson).call([_invocation]); 83 | } 84 | } else { 85 | responseExpr = _invocation; 86 | } 87 | 88 | yield Types.shelfResponse 89 | .newInstanceNamed('ok', [ 90 | Constants.json.property('encode').call([responseExpr]), 91 | ], _extraParams('json')) 92 | .returned 93 | .statement; 94 | } 95 | 96 | Map _extraParams( 97 | String typeName, [ 98 | Reference? encoding, 99 | ]) => { 100 | 'headers': literalMap({ 101 | HttpHeaders.contentTypeHeader: Types.contentTypes.property(typeName), 102 | }), 103 | 'encoding': ?encoding, 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/client/client_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart' hide MethodBuilder; 2 | import 'package:meta/meta.dart'; 3 | 4 | import '../../models/api_class.dart'; 5 | import '../../models/endpoint_response.dart'; 6 | import '../../util/types.dart'; 7 | import '../base/spec_builder.dart'; 8 | import 'method_builder.dart'; 9 | 10 | @internal 11 | final class ClientBuilder extends SpecBuilder { 12 | static const _dioRef = Reference('_dio'); 13 | static const _baseUrlRef = Reference('baseUrl'); 14 | static const _baseOptionsRef = Reference('baseOptions'); 15 | 16 | final ApiClass _apiClass; 17 | 18 | const ClientBuilder(this._apiClass); 19 | 20 | @override 21 | Class build() => Class( 22 | (b) => b 23 | ..name = _apiClass.clientName 24 | ..fields.add( 25 | Field( 26 | (b) => b 27 | ..name = _dioRef.symbol 28 | ..modifier = FieldModifier.final$ 29 | ..type = Types.dio, 30 | ), 31 | ) 32 | ..constructors.add(_buildDefaultConstructor()) 33 | ..constructors.add(_buildOptionsConstructor()) 34 | ..constructors.add(_buildDioConstructor()) 35 | ..methods.addAll(_buildMethods()) 36 | ..methods.add(_buildClose()), 37 | ); 38 | 39 | Constructor _buildDefaultConstructor() => Constructor( 40 | (b) => b 41 | ..requiredParameters.add( 42 | Parameter( 43 | (b) => b 44 | ..name = _baseUrlRef.symbol! 45 | ..type = Types.uri, 46 | ), 47 | ) 48 | ..initializers.add( 49 | _dioRef 50 | .assign( 51 | Types.dio.newInstance([ 52 | Types.baseOptions.newInstance(const [], { 53 | 'baseUrl': _baseUrlRef.property('toString').call(const []), 54 | }), 55 | ]), 56 | ) 57 | .code, 58 | ), 59 | ); 60 | 61 | Constructor _buildOptionsConstructor() => Constructor( 62 | (b) => b 63 | ..name = 'options' 64 | ..requiredParameters.add( 65 | Parameter( 66 | (b) => b 67 | ..name = _baseOptionsRef.symbol! 68 | ..type = Types.baseOptions, 69 | ), 70 | ) 71 | ..initializers.add( 72 | _dioRef.assign(Types.dio.newInstance([_baseOptionsRef])).code, 73 | ), 74 | ); 75 | 76 | Constructor _buildDioConstructor() => Constructor( 77 | (b) => b 78 | ..name = 'dio' 79 | ..requiredParameters.add( 80 | Parameter( 81 | (b) => b 82 | ..name = _dioRef.symbol! 83 | ..toThis = true, 84 | ), 85 | ), 86 | ); 87 | 88 | Iterable _buildMethods() sync* { 89 | for (final endpoint in _apiClass.endpoints) { 90 | for (final method in endpoint.methods) { 91 | final methodBuilder = MethodBuilder( 92 | _apiClass, 93 | endpoint, 94 | method, 95 | _dioRef, 96 | ); 97 | yield methodBuilder.build(); 98 | if (method.response.responseType != EndpointResponseType.dynamic) { 99 | yield methodBuilder.buildRaw(); 100 | } 101 | } 102 | } 103 | } 104 | 105 | Method _buildClose() => Method( 106 | (b) => b 107 | ..name = 'close' 108 | ..returns = Types.void$ 109 | ..optionalParameters.add( 110 | Parameter( 111 | (b) => b 112 | ..name = 'force' 113 | ..type = Types.bool$ 114 | ..named = true 115 | ..defaultTo = literalFalse.code, 116 | ), 117 | ) 118 | ..body = _dioRef.property('close').call(const [], { 119 | 'force': refer('force'), 120 | }).code, 121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/common/from_json_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | import '../../models/opaque_constant.dart'; 5 | import '../../models/opaque_type.dart'; 6 | import '../../models/serializable_type.dart'; 7 | import '../../util/constants.dart'; 8 | import '../../util/extensions/code_builder_extensions.dart'; 9 | import '../../util/types.dart'; 10 | 11 | @internal 12 | class FromJsonBuilder { 13 | final SerializableType _serializableType; 14 | 15 | FromJsonBuilder(this._serializableType); 16 | 17 | TypeReference get rawJsonType { 18 | if (_serializableType.fromJson != null) { 19 | return Types.dynamic$; 20 | } else { 21 | return switch (_serializableType.wrapped) { 22 | Wrapped.none => switch (_serializableType.jsonType) { 23 | final OpaqueType jsonType => Types.fromType(jsonType), 24 | _ => Types.fromType(_serializableType.dartType), 25 | }, 26 | Wrapped.list => Types.list().withNullable(_serializableType.isNullable), 27 | Wrapped.map => Types.map( 28 | keyType: Types.string, 29 | ).withNullable(_serializableType.isNullable), 30 | }; 31 | } 32 | } 33 | 34 | Expression buildFromJson(Expression jsonBody) => 35 | switch (_serializableType.wrapped) { 36 | Wrapped.none => _buildJson(jsonBody), 37 | Wrapped.list => _buildList(jsonBody), 38 | Wrapped.map => _buildMap(jsonBody), 39 | }; 40 | 41 | Expression _buildJson(Expression jsonBody) { 42 | var checkNull = false; 43 | Expression paramExpr; 44 | if (_serializableType.fromJson case final OpaqueConstant fromJson) { 45 | checkNull = true; 46 | paramExpr = Constants.fromConstant(fromJson).call([jsonBody]); 47 | } else if (_serializableType.jsonType != null) { 48 | checkNull = true; 49 | paramExpr = Types.fromType( 50 | _serializableType.dartType, 51 | ).withNullable(false).newInstanceNamed('fromJson', [jsonBody]); 52 | } else { 53 | paramExpr = jsonBody; 54 | } 55 | 56 | if (checkNull && _serializableType.isNullable) { 57 | paramExpr = jsonBody 58 | .notEqualTo(literalNull) 59 | .conditional(paramExpr, literalNull); 60 | } 61 | 62 | return paramExpr; 63 | } 64 | 65 | Expression _buildList(Expression jsonBody) { 66 | if (_serializableType.jsonType case final OpaqueType jsonType) { 67 | return jsonBody 68 | .autoProperty('cast', _serializableType.isNullable) 69 | .call(const [], const {}, [Types.fromType(jsonType)]) 70 | .property('map') 71 | .call([ 72 | Types.fromType(_serializableType.dartType).property('fromJson'), 73 | ]) 74 | .property('toList') 75 | .call(const []); 76 | } else { 77 | return jsonBody 78 | .autoProperty('cast', _serializableType.isNullable) 79 | .call(const [], const {}, [ 80 | Types.fromType(_serializableType.dartType), 81 | ]) 82 | .property('toList') 83 | .call(const []); 84 | } 85 | } 86 | 87 | Expression _buildMap(Expression jsonBody) { 88 | if (_serializableType.jsonType case final OpaqueType jsonType) { 89 | return jsonBody 90 | .autoProperty('cast', _serializableType.isNullable) 91 | .call(const [], const {}, [Types.string, Types.fromType(jsonType)]) 92 | .property('mapValue') 93 | .call([ 94 | Types.fromType(_serializableType.dartType).property('fromJson'), 95 | ]); 96 | } else { 97 | return jsonBody.autoProperty('cast', _serializableType.isNullable).call( 98 | const [], 99 | const {}, 100 | [Types.string, Types.fromType(_serializableType.dartType)], 101 | ); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /packages/shelf_api/test/unit/client/t_response_body_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:dio/dio.dart'; 5 | import 'package:shelf_api/src/api/http_method.dart'; 6 | import 'package:shelf_api/src/client/t_response_body.dart'; 7 | import 'package:test/test.dart'; 8 | 9 | void main() { 10 | group('TResponseBody', () { 11 | const testData = 42; 12 | const testStatusCode = HttpStatus.ok; 13 | const testStatusMessage = 'HTTP OK'; 14 | const testIsRedirect = true; 15 | final testRedirects = [ 16 | RedirectRecord(HttpStatus.movedPermanently, HttpMethod.get, Uri()), 17 | ]; 18 | final testHeaders = Headers.fromMap(const { 19 | 'a': ['1'], 20 | 'b': ['4', '2'], 21 | }); 22 | 23 | test('default constructor', () { 24 | final sut = TResponseBody( 25 | data: testData, 26 | statusCode: testStatusCode, 27 | statusMessage: testStatusMessage, 28 | isRedirect: testIsRedirect, 29 | redirects: testRedirects, 30 | headers: testHeaders, 31 | ); 32 | 33 | expect(sut.data, testData); 34 | expect(sut.statusCode, testStatusCode); 35 | expect(sut.statusMessage, testStatusMessage); 36 | expect(sut.isRedirect, testIsRedirect); 37 | expect(sut.redirects, testRedirects); 38 | expect(sut.headers.map, testHeaders.map); 39 | }); 40 | 41 | test('fromResponse constructor', () { 42 | final sut = TResponseBody.fromResponse( 43 | Response( 44 | data: 'test', 45 | requestOptions: RequestOptions(), 46 | statusCode: testStatusCode, 47 | statusMessage: testStatusMessage, 48 | headers: testHeaders, 49 | isRedirect: testIsRedirect, 50 | redirects: testRedirects, 51 | ), 52 | testData, 53 | ); 54 | 55 | expect(sut.data, testData); 56 | expect(sut.statusCode, testStatusCode); 57 | expect(sut.statusMessage, testStatusMessage); 58 | expect(sut.isRedirect, testIsRedirect); 59 | expect(sut.redirects, testRedirects); 60 | expect(sut.headers.map, testHeaders.map); 61 | }); 62 | 63 | test('fromResponse constructor', () { 64 | final sut = TResponseBody.fromResponseBody( 65 | ResponseBody( 66 | Stream.value(Uint8List.fromList([1, 2, 3])), 67 | testStatusCode, 68 | statusMessage: testStatusMessage, 69 | headers: testHeaders.map, 70 | isRedirect: testIsRedirect, 71 | redirects: testRedirects, 72 | ), 73 | testData, 74 | ); 75 | 76 | expect(sut.data, testData); 77 | expect(sut.statusCode, testStatusCode); 78 | expect(sut.statusMessage, testStatusMessage); 79 | expect(sut.isRedirect, testIsRedirect); 80 | expect(sut.redirects, testRedirects); 81 | expect(sut.headers.map, testHeaders.map); 82 | }); 83 | 84 | group('contentLength', () { 85 | test('returns parsed header value', () { 86 | final sut = TResponseBody( 87 | data: testData, 88 | statusCode: testStatusCode, 89 | statusMessage: testStatusMessage, 90 | isRedirect: testIsRedirect, 91 | redirects: testRedirects, 92 | headers: Headers.fromMap({ 93 | HttpHeaders.contentLengthHeader: ['5134'], 94 | }), 95 | ); 96 | 97 | expect(sut.contentLength, 5134); 98 | }); 99 | 100 | test('returns -1 if header is not set', () { 101 | final sut = TResponseBody( 102 | data: testData, 103 | statusCode: testStatusCode, 104 | statusMessage: testStatusMessage, 105 | isRedirect: testIsRedirect, 106 | redirects: testRedirects, 107 | headers: testHeaders, 108 | ); 109 | 110 | expect(sut.contentLength, -1); 111 | }); 112 | }); 113 | }); 114 | } 115 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/analyzers/path_analyzer.dart: -------------------------------------------------------------------------------- 1 | import 'package:analyzer/dart/element/element.dart'; 2 | import 'package:build/build.dart'; 3 | import 'package:meta/meta.dart'; 4 | import 'package:source_gen/source_gen.dart'; 5 | import 'package:source_helper/source_helper.dart'; 6 | 7 | import '../models/endpoint_path_parameter.dart'; 8 | import '../models/opaque_type.dart'; 9 | import '../readers/api_method_reader.dart'; 10 | import '../readers/body_param_reader.dart'; 11 | import '../readers/path_param_reader.dart'; 12 | import '../util/type_checkers.dart'; 13 | 14 | @internal 15 | class PathAnalyzer { 16 | // Based on https://github.com/dart-lang/shelf/blob/master/pkgs/shelf_router/lib/src/router_entry.dart#L31 17 | final _pathParamRegexp = RegExp(r'<([^>|]+)(?:\|[^>]*)?>'); 18 | 19 | final BuildStep _buildStep; 20 | 21 | PathAnalyzer(this._buildStep); 22 | 23 | Future> analyzePath( 24 | MethodElement method, 25 | ApiMethodReader apiMethod, 26 | ) => _analyzePath(method, apiMethod).toList(); 27 | 28 | Stream _analyzePath( 29 | MethodElement method, 30 | ApiMethodReader apiMethod, 31 | ) async* { 32 | final pathParamMatches = _pathParamRegexp 33 | .allMatches(apiMethod.path) 34 | .map((match) => match[1]!) 35 | .toList(); 36 | 37 | for (final param in method.formalParameters.where((p) => p.isPositional)) { 38 | if (param.bodyParamAnnotation != null) { 39 | if (pathParamMatches.isEmpty) { 40 | continue; 41 | } 42 | 43 | throw InvalidGenerationSource( 44 | 'Found ${pathParamMatches.length} remaining path parameter in URL ' 45 | 'template, positional parameter is marked as bodyParam', 46 | todo: 'Ensure parameters always occur before the body parameter', 47 | element: method, 48 | ); 49 | } 50 | 51 | if (!param.isRequired || param.type.isNullableType) { 52 | throw InvalidGenerationSource( 53 | 'Path parameters cannot be optional nullable.', 54 | todo: 'Make parameter required and non nullable', 55 | element: param, 56 | ); 57 | } 58 | 59 | if (pathParamMatches.isEmpty) { 60 | throw InvalidGenerationSource( 61 | 'Unable to find parameter named ${param.name} in the URL template ' 62 | 'of the endpoint method', 63 | todo: 'Ensure parameters are correctly named in both URL and method.', 64 | element: param, 65 | ); 66 | } 67 | 68 | final matchName = pathParamMatches.removeAt(0); 69 | if (matchName != param.name) { 70 | throw InvalidGenerationSource( 71 | 'Expected ${param.name} in the URL template but found $matchName. ' 72 | 'Make sure template parameters and method parameters are in the same ' 73 | 'order!', 74 | todo: 'Reorder or rename your method parameters.', 75 | element: param, 76 | ); 77 | } 78 | 79 | final pathParam = param.pathParamAnnotation; 80 | final paramType = param.type; 81 | yield EndpointPathParameter( 82 | name: param.name!, 83 | type: OpaqueDartType(_buildStep, paramType), 84 | isString: paramType.isDartCoreString, 85 | isEnum: paramType.isEnum, 86 | isDateTime: TypeCheckers.dateTime.isExactlyType(paramType), 87 | customParse: await pathParam?.parse(_buildStep), 88 | customToString: await pathParam?.stringify(_buildStep), 89 | urlEncode: pathParam?.urlEncode ?? true, 90 | ); 91 | } 92 | 93 | for (final match in pathParamMatches) { 94 | throw InvalidGenerationSource( 95 | 'Found path parameter $match in URL template, but no matching ' 96 | 'method parameter was found.', 97 | todo: 'Ensure parameters are correctly named in both URL and method.', 98 | element: method, 99 | ); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/src/annotations/api_method.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:meta/meta_meta.dart'; 4 | 5 | import '../api/http_method.dart'; 6 | 7 | /// Marks the given method as a specific endpoint method. 8 | @Target({TargetKind.method}) 9 | class ApiMethod { 10 | /// The HTTP method this method will be called for. 11 | final String method; 12 | 13 | /// The path to this API method. 14 | /// 15 | /// [path] must always start with a slash and may or may not end with one. 16 | /// See routing table below for how this makes a difference. 17 | /// 18 | /// Example Routing Table: 19 | /// Endpoint Route | Method Route | Resolves to 20 | /// ----------------|--------------|------- 21 | /// *none* | / | / 22 | /// *none* | /users | /users 23 | /// *none* | /users/ | /users/ 24 | /// / | / | / 25 | /// / | /users | /users 26 | /// / | /users/ | /users/ 27 | /// /api | / | /api *or* /api/ 28 | /// /api | /users | /api/users 29 | /// /api | /users/ | /api/users/ 30 | /// /api/ | / | /api/ 31 | /// /api/ | /users | /api/users 32 | /// /api/ | /users/ | /api/users/ 33 | final String path; 34 | 35 | /// Custom deserialization function for the response. 36 | /// 37 | /// You can use this in case the response type does not have a fromJson 38 | /// constructor or you need to customize the default deserialization behavior 39 | /// for it. It must have the following signature: 40 | /// 41 | /// ```dart 42 | /// T Function(dynamic) 43 | /// ``` 44 | final Function? fromJson; 45 | 46 | /// Custom serialization function for the response. 47 | /// 48 | /// You can use this in case the response type does not have a toJson method 49 | /// or you need to customize the default serialization behavior for it. It 50 | /// must have the following signature: 51 | /// 52 | /// ```dart 53 | /// dynamic Function(T) 54 | /// ``` 55 | final Function? toJson; 56 | 57 | /// Constructor. 58 | const ApiMethod(this.method, this.path, {this.fromJson, this.toJson}); 59 | } 60 | 61 | /// Marks the given method as a get endpoint method. 62 | @Target({TargetKind.method}) 63 | class Get extends ApiMethod { 64 | /// Constructor. 65 | const Get(String path, {super.fromJson, super.toJson}) 66 | : super(HttpMethod.get, path); 67 | } 68 | 69 | /// Marks the given method as a delete endpoint method. 70 | @Target({TargetKind.method}) 71 | class Delete extends ApiMethod { 72 | /// Constructor. 73 | const Delete(String path, {super.fromJson, super.toJson}) 74 | : super(HttpMethod.delete, path); 75 | } 76 | 77 | /// Marks the given method as a head endpoint method. 78 | @Target({TargetKind.method}) 79 | class Head extends ApiMethod { 80 | /// Constructor. 81 | const Head(String path, {super.fromJson, super.toJson}) 82 | : super(HttpMethod.head, path); 83 | } 84 | 85 | /// Marks the given method as a options endpoint method. 86 | @Target({TargetKind.method}) 87 | class Options extends ApiMethod { 88 | /// Constructor. 89 | const Options(String path, {super.fromJson, super.toJson}) 90 | : super(HttpMethod.options, path); 91 | } 92 | 93 | /// Marks the given method as a patch endpoint method. 94 | @Target({TargetKind.method}) 95 | class Patch extends ApiMethod { 96 | /// Constructor. 97 | const Patch(String path, {super.fromJson, super.toJson}) 98 | : super(HttpMethod.patch, path); 99 | } 100 | 101 | /// Marks the given method as a post endpoint method. 102 | @Target({TargetKind.method}) 103 | class Post extends ApiMethod { 104 | /// Constructor. 105 | const Post(String path, {super.fromJson, super.toJson}) 106 | : super(HttpMethod.post, path); 107 | } 108 | 109 | /// Marks the given method as a put endpoint method. 110 | @Target({TargetKind.method}) 111 | class Put extends ApiMethod { 112 | /// Constructor. 113 | const Put(String path, {super.fromJson, super.toJson}) 114 | : super(HttpMethod.put, path); 115 | } 116 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/client/method_body_builder.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:code_builder/code_builder.dart'; 4 | import 'package:meta/meta.dart'; 5 | 6 | import '../../models/api_class.dart'; 7 | import '../../models/endpoint.dart'; 8 | import '../../models/endpoint_body.dart'; 9 | import '../../models/endpoint_method.dart'; 10 | import '../../models/endpoint_response.dart'; 11 | import '../../util/types.dart'; 12 | import '../base/code_builder.dart'; 13 | import '../common/from_json_builder.dart'; 14 | import 'body_builder.dart'; 15 | import 'path_builder.dart'; 16 | import 'query_builder.dart'; 17 | import 'response_builder.dart'; 18 | 19 | @internal 20 | final class MethodBodyBuilder extends CodeBuilder { 21 | final ApiClass _apiClass; 22 | final Endpoint _endpoint; 23 | final EndpointMethod _method; 24 | final Reference _dioRef; 25 | final Reference _optionsRef; 26 | final List _extraParamsRefs; 27 | final bool _isRaw; 28 | 29 | const MethodBodyBuilder( 30 | this._apiClass, 31 | this._endpoint, 32 | this._method, 33 | this._dioRef, 34 | this._optionsRef, 35 | this._extraParamsRefs, 36 | // ignore: avoid_positional_boolean_parameters for private param 37 | this._isRaw, 38 | ); 39 | 40 | @override 41 | Iterable build() sync* { 42 | final queryBuilder = QueryBuilder(_method.queryParameters); 43 | final invocation = _dioRef.property('request').call( 44 | [PathBuilder(_apiClass, _endpoint, _method)], 45 | { 46 | if (_method.body case final EndpointBody body) 47 | 'data': BodyBuilder(body), 48 | if (queryBuilder.hasParams) 'queryParameters': queryBuilder.build(), 49 | _optionsRef.symbol!.substring(1): _optionsRef 50 | .ifNullThen(Types.options.newInstance(const [])) 51 | .parenthesized 52 | .property('copyWith') 53 | .call(const [], _options), 54 | for (final param in _extraParamsRefs) param.symbol!.substring(1): param, 55 | }, 56 | [_responseDartType], 57 | ); 58 | 59 | yield ResponseBuilder(_method.response, invocation.awaited, _isRaw); 60 | } 61 | 62 | Map get _options => { 63 | 'method': literalString(_method.httpMethod), 64 | 'responseType': _responseType, 65 | if (_method.body?.contentTypes case [final firstContentType, ...]) 66 | 'contentType': literalString(firstContentType) 67 | else if (_method.body?.bodyType case final EndpointBodyType bodyType) 68 | 'contentType': switch (bodyType) { 69 | EndpointBodyType.text || 70 | EndpointBodyType.textStream => literalString(ContentType.text.mimeType), 71 | EndpointBodyType.binary || EndpointBodyType.binaryStream => 72 | literalString(ContentType.binary.mimeType), 73 | EndpointBodyType.json => literalString(ContentType.json.mimeType), 74 | }, 75 | }; 76 | 77 | TypeReference get _responseDartType { 78 | switch (_method.response.responseType) { 79 | case EndpointResponseType.noContent: 80 | return Types.void$; 81 | case EndpointResponseType.text: 82 | return Types.string; 83 | case EndpointResponseType.binary: 84 | return Types.uint8List; 85 | case EndpointResponseType.textStream: 86 | case EndpointResponseType.binaryStream: 87 | case EndpointResponseType.dynamic: 88 | return Types.responseBody; 89 | case EndpointResponseType.json: 90 | return FromJsonBuilder( 91 | _method.response.serializableReturnType, 92 | ).rawJsonType; 93 | } 94 | } 95 | 96 | Expression get _responseType => switch (_method.response.responseType) { 97 | EndpointResponseType.noContent => literalNull, 98 | EndpointResponseType.text => Types.responseType.property('plain'), 99 | EndpointResponseType.binary => Types.responseType.property('bytes'), 100 | EndpointResponseType.textStream || 101 | EndpointResponseType.binaryStream || 102 | EndpointResponseType.dynamic => Types.responseType.property('stream'), 103 | EndpointResponseType.json => Types.responseType.property('json'), 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/client/response_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | import '../../models/endpoint_response.dart'; 5 | import '../../util/code/if.dart'; 6 | import '../../util/constants.dart'; 7 | import '../../util/extensions/code_builder_extensions.dart'; 8 | import '../../util/types.dart'; 9 | import '../base/code_builder.dart'; 10 | import '../common/from_json_builder.dart'; 11 | 12 | @internal 13 | final class ResponseBuilder extends CodeBuilder { 14 | static const _responseRef = Reference(r'$response'); 15 | static const _responseDataRef = Reference(r'$responseData'); 16 | 17 | final EndpointResponse _response; 18 | final Expression _invocation; 19 | final bool _isRaw; 20 | 21 | // ignore: avoid_positional_boolean_parameters for private param 22 | const ResponseBuilder(this._response, this._invocation, this._isRaw); 23 | 24 | @override 25 | Iterable build() sync* { 26 | if (!_isRaw && _response.responseType == EndpointResponseType.noContent) { 27 | yield _invocation.statement; 28 | } else { 29 | yield declareFinal(_responseRef.symbol!).assign(_invocation).statement; 30 | } 31 | 32 | switch (_response.responseType) { 33 | case EndpointResponseType.noContent: 34 | if (_isRaw) { 35 | yield _returned(literalNull); 36 | } 37 | case EndpointResponseType.text: 38 | yield _returned( 39 | _responseRef.property('data').ifNullThen(literalString('')), 40 | ); 41 | case EndpointResponseType.binary: 42 | yield _returned( 43 | _responseRef 44 | .property('data') 45 | .ifNullThen(Types.uint8List.newInstance([literalNum(0)])), 46 | ); 47 | case EndpointResponseType.textStream: 48 | yield _buildStreamReturn( 49 | (stream) => stream 50 | .property('cast') 51 | .call(const [], const {}, [Types.list(Types.int$)]) 52 | .property('transform') 53 | .call([Constants.utf8.property('decoder')]), 54 | ); 55 | case EndpointResponseType.binaryStream: 56 | yield _buildStreamReturn(); 57 | case EndpointResponseType.json: 58 | yield* _buildJsonReturn(); 59 | case EndpointResponseType.dynamic: 60 | yield _returned(_responseRef.property('data').nullChecked); 61 | } 62 | } 63 | 64 | Code _buildStreamReturn([ 65 | Expression Function(Expression stream) transform = _transformNoop, 66 | ]) { 67 | final transformedStream = transform( 68 | _responseRef.property('data').nullChecked.property('stream'), 69 | ); 70 | return _isRaw 71 | ? _returned(transformedStream) 72 | : transformedStream.yieldedStar.statement; 73 | } 74 | 75 | Iterable _buildJsonReturn() sync* { 76 | yield declareFinal( 77 | _responseDataRef.symbol!, 78 | ).assign(_responseRef.property('data')).statement; 79 | 80 | final serializableType = _response.serializableReturnType; 81 | if (!serializableType.isNullable) { 82 | yield If( 83 | _responseDataRef.equalTo(literalNull), 84 | Types.dioException 85 | .newInstance(const [], { 86 | 'requestOptions': _responseRef.property('requestOptions'), 87 | 'response': _responseRef, 88 | 'type': Types.dioExceptionType.property('badResponse'), 89 | 'message': literalString( 90 | 'Received JSON response with null body, but empty responses ' 91 | 'are not allowed!', 92 | ), 93 | }) 94 | .thrown 95 | .statement, 96 | ); 97 | } 98 | 99 | final fromJsonBuilder = FromJsonBuilder(serializableType); 100 | yield _returned(fromJsonBuilder.buildFromJson(_responseDataRef)); 101 | } 102 | 103 | Code _returned(Expression expression) => _isRaw 104 | ? Types.tResponseBody() 105 | .newInstanceNamed('fromResponse', [_responseRef, expression]) 106 | .returned 107 | .statement 108 | : expression.returned.statement; 109 | 110 | static Expression _transformNoop(Expression expr) => expr; 111 | } 112 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/api/query_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | import '../../models/endpoint_query_parameter.dart'; 5 | import '../../models/opaque_constant.dart'; 6 | import '../../util/code/if.dart'; 7 | import '../../util/constants.dart'; 8 | import '../../util/types.dart'; 9 | import '../base/code_builder.dart'; 10 | 11 | @internal 12 | final class QueryBuilder { 13 | final List _queryParameters; 14 | final Reference _requestRef; 15 | 16 | const QueryBuilder(this._queryParameters, this._requestRef); 17 | 18 | Code get variables => _queryParameters.isNotEmpty 19 | ? _QueryVariablesBuilder(_queryParameters, _requestRef) 20 | : const Code(''); 21 | 22 | Map get parameters => _queryParameters.isNotEmpty 23 | ? Map.fromEntries(_QueryParamsBuilder(_queryParameters).build()) 24 | : const {}; 25 | } 26 | 27 | final class _QueryVariablesBuilder extends CodeBuilder { 28 | static const _queryRef = Reference(r'$query'); 29 | 30 | final List _queryParameters; 31 | final Reference _requestRef; 32 | 33 | const _QueryVariablesBuilder(this._queryParameters, this._requestRef); 34 | 35 | @override 36 | Iterable build() sync* { 37 | yield declareFinal(_queryRef.symbol!) 38 | .assign(_requestRef.property('url').property('queryParametersAll')) 39 | .statement; 40 | 41 | for (final param in _queryParameters) { 42 | final paramRef = refer(param.handlerParamName); 43 | var getValueExpr = _queryRef.index( 44 | literalString(param.queryName, raw: true), 45 | ); 46 | if (!param.isList) { 47 | getValueExpr = getValueExpr.nullSafeProperty('firstOrNull'); 48 | } 49 | yield declareFinal(paramRef.symbol!).assign(getValueExpr).statement; 50 | 51 | if (!param.isOptional) { 52 | yield If( 53 | paramRef.equalTo(literalNull), 54 | Types.shelfResponse 55 | .newInstanceNamed('badRequest', const [], { 56 | 'body': literalString( 57 | 'Missing required query parameter ${param.queryName}', 58 | raw: true, 59 | ), 60 | }) 61 | .returned 62 | .statement, 63 | ); 64 | } 65 | } 66 | } 67 | } 68 | 69 | final class _QueryParamsBuilder { 70 | final List _queryParameters; 71 | 72 | _QueryParamsBuilder(this._queryParameters); 73 | 74 | Iterable> build() sync* { 75 | for (final param in _queryParameters) { 76 | final paramRef = refer(param.handlerParamName); 77 | final convertExpression = _convertExpression(param, paramRef); 78 | 79 | if (param.isOptional) { 80 | if (param.defaultValue case final String code) { 81 | yield MapEntry( 82 | param.paramName, 83 | paramRef 84 | .notEqualTo(literalNull) 85 | .conditional(convertExpression, CodeExpression(Code(code))), 86 | ); 87 | } else { 88 | yield MapEntry( 89 | param.paramName, 90 | paramRef 91 | .notEqualTo(literalNull) 92 | .conditional(convertExpression, literalNull), 93 | ); 94 | } 95 | } else { 96 | yield MapEntry(param.paramName, convertExpression); 97 | } 98 | } 99 | } 100 | 101 | Expression _convertExpression( 102 | EndpointQueryParameter param, 103 | Reference paramRef, 104 | ) { 105 | if (param.customParse case final OpaqueConstant parse) { 106 | return Constants.fromConstant(parse).call([paramRef]); 107 | } 108 | 109 | if (param.isString) { 110 | return paramRef; 111 | } 112 | 113 | if (param.isList) { 114 | return paramRef 115 | .property('map') 116 | .call([_convertNonStringExpression(param)]) 117 | .property('toList') 118 | .call(const []); 119 | } 120 | 121 | return _convertNonStringExpression(param).call([paramRef]); 122 | } 123 | 124 | Expression _convertNonStringExpression(EndpointQueryParameter param) { 125 | if (param.isEnum) { 126 | return Types.fromType( 127 | param.type, 128 | isNull: false, 129 | ).property('values').property('byName'); 130 | } 131 | 132 | return Types.fromType(param.type, isNull: false).property('parse'); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/api/api_implementation_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | import 'package:meta/meta.dart'; 3 | 4 | import '../../models/api_class.dart'; 5 | import '../../models/endpoint.dart'; 6 | import '../../models/opaque_constant.dart'; 7 | import '../../util/constants.dart'; 8 | import '../../util/types.dart'; 9 | import '../base/spec_builder.dart'; 10 | import 'api_handler_builder.dart'; 11 | 12 | @internal 13 | final class ApiImplementationBuilder extends SpecBuilder { 14 | static const _handlerRef = Reference(r'_$handler'); 15 | static const _requestRef = Reference('request'); 16 | 17 | final ApiClass _apiClass; 18 | 19 | ApiImplementationBuilder(this._apiClass); 20 | 21 | @override 22 | Class build() => Class( 23 | (b) => b 24 | ..name = _apiClass.implementationName 25 | ..fields.add( 26 | Field( 27 | (b) => b 28 | ..name = _handlerRef.symbol 29 | ..late = true 30 | ..modifier = FieldModifier.final$ 31 | ..type = Types.handler, 32 | ), 33 | ) 34 | ..constructors.add( 35 | Constructor((b) => b..body = Block.of(_buildConstructorBody())), 36 | ) 37 | ..methods.add( 38 | Method( 39 | (b) => b 40 | ..name = 'call' 41 | ..returns = Types.futureOr(Types.shelfResponse) 42 | ..requiredParameters.add( 43 | Parameter( 44 | (b) => b 45 | ..name = 'request' 46 | ..type = Types.shelfRequest, 47 | ), 48 | ) 49 | ..body = ApiImplementationBuilder._handlerRef.call([ 50 | ApiImplementationBuilder._requestRef, 51 | ]).code, 52 | ), 53 | ) 54 | ..methods.addAll([ 55 | for (final endpoint in _apiClass.endpoints) 56 | for (final method in endpoint.methods) 57 | ApiHandlerBuilder(endpoint, method).build(), 58 | ]), 59 | ); 60 | 61 | Iterable _buildConstructorBody() sync* { 62 | var handler = Types.router.newInstance(const []); 63 | 64 | handler = _routeEndpoints(handler); 65 | 66 | if (_apiClass.middleware case final OpaqueConstant middleware) { 67 | handler = _withMiddleware(handler, middleware); 68 | } 69 | 70 | if (_apiClass.basePath case final String path) { 71 | handler = Types.router.newInstance(const []).cascade('mount').call([ 72 | literalString(path, raw: true), 73 | handler, 74 | ]); 75 | } 76 | 77 | yield _handlerRef.assign(handler).statement; 78 | } 79 | 80 | Expression _routeEndpoints(Expression router) { 81 | var currentRouterRef = router; 82 | for (final endpoint in _apiClass.endpoints) { 83 | currentRouterRef = _routeEndpoint(currentRouterRef, endpoint); 84 | } 85 | return currentRouterRef; 86 | } 87 | 88 | Expression _routeEndpoint(Expression router, Endpoint endpoint) { 89 | final middleware = endpoint.middleware; 90 | final endpointNeedsRouter = endpoint.path != null || middleware != null; 91 | 92 | var endpointRouter = endpointNeedsRouter 93 | ? Types.router.newInstance(const []) 94 | : router; 95 | 96 | endpointRouter = _routeMethods(endpointRouter, endpoint); 97 | 98 | if (middleware != null) { 99 | endpointRouter = _withMiddleware(endpointRouter, middleware); 100 | } 101 | 102 | if (endpointNeedsRouter) { 103 | return router.cascade('mount').call([ 104 | literalString(endpoint.path ?? '/', raw: true), 105 | endpointRouter, 106 | ]); 107 | } else { 108 | return endpointRouter; 109 | } 110 | } 111 | 112 | Expression _routeMethods(Expression router, Endpoint endpoint) { 113 | var currentRouterRef = router; 114 | for (final method in endpoint.methods) { 115 | currentRouterRef = currentRouterRef.cascade('add').call([ 116 | literalString(method.httpMethod), 117 | literalString(method.path, raw: true), 118 | refer(ApiHandlerBuilder.handlerMethodName(endpoint, method)), 119 | ]); 120 | } 121 | return currentRouterRef; 122 | } 123 | 124 | Expression _withMiddleware(Expression handler, OpaqueConstant middleware) => 125 | Types.pipeline 126 | .constInstance(const []) 127 | .property('addMiddleware') 128 | .call([Constants.fromConstant(middleware).call(const [])]) 129 | .property('addHandler') 130 | .call([handler]); 131 | } 132 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/src/riverpod/rivershelf.dart: -------------------------------------------------------------------------------- 1 | import 'package:riverpod/riverpod.dart'; 2 | import 'package:riverpod_annotation/riverpod_annotation.dart'; 3 | import 'package:shelf/shelf.dart'; 4 | 5 | import 'endpoint_ref.dart'; 6 | 7 | /// A key to get the original [EndpointRef] from [Request.context]. 8 | /// 9 | /// Can only be used if the response was created by the 10 | /// [rivershelf] middleware. 11 | const rivershelfRefKey = 'rivershelf.ref'; 12 | 13 | /// A middleware to make riverpod available for route handlers. 14 | /// 15 | /// To obtain an [EndpointRef], which must be used to actually access the 16 | /// [ProviderContainer], you can simply use the 17 | /// [RequestRivershelfExtension.ref] extension: `context.ref`. 18 | /// 19 | /// The [parent], [overrides] and [observers] parameters are passed as is to 20 | /// the internally created [ProviderContainer]. Checkout [ProviderContainer.new] 21 | /// for more details. 22 | /// 23 | /// See [rivershelfContainer] if you need to create the middleware from an 24 | /// already existing [ProviderContainer]. This may be useful if you need to make 25 | /// sure the containers gets disposed properly, as the middleware will never 26 | /// dispose the container by itself (As usually, this is not needed). 27 | Middleware rivershelf({ 28 | ProviderContainer? parent, 29 | List overrides = const [], 30 | List? observers, 31 | }) => _RivershelfMiddleware( 32 | parent: parent, 33 | overrides: overrides, 34 | observers: observers, 35 | ).call; 36 | 37 | /// A middleware to make riverpod available for route handlers. 38 | /// 39 | /// Variant of [rivershelf] that takes a [container] as the application 40 | /// provider container instead of creating one itself. 41 | Middleware rivershelfContainer(ProviderContainer container) => 42 | _RivershelfMiddleware.fromContainer(container).call; 43 | 44 | /// An extension on [Request] to access the [EndpointRef]. 45 | extension RequestRivershelfExtension on Request { 46 | static final _requestOverrides = Expando( 47 | 'RequestRivershelfExtension.requestOverrides', 48 | ); 49 | 50 | /// Returns the associated [EndpointRef]. 51 | /// 52 | /// Only works if the [rivershelf] middleware is available in this context. 53 | /// 54 | /// Note: In addition to fetching the [ref], this also ensures that 55 | /// [ProviderContainer]s [Request] is up to date. If not, the 56 | /// [shelfRequestProvider] is updated with this request to make the newest 57 | /// value available. This has no effect on normal providers, but request 58 | /// providers may be invalidated because of this. Usually, this should not 59 | /// affect use in any way, but if a middleware interacts with a request scoped 60 | /// provider and updates it, then that provider may be destroyed and recreated 61 | /// if accessed from withing the actual handler. If this is a problem, ensure 62 | /// that you provider can retain it's state even when rebuild. 63 | EndpointRef get ref { 64 | assert( 65 | context[rivershelfRefKey] is EndpointRef, 66 | 'Cannot use request.ref without registering the rivershelf ' 67 | 'middleware first!', 68 | ); 69 | final endpointRef = context[rivershelfRefKey]! as EndpointRef; 70 | 71 | if (!(_requestOverrides[this] ?? false)) { 72 | _requestOverrides[this] = true; 73 | endpointRef.container.updateOverrides([ 74 | shelfRequestProvider.overrideWithValue(this), 75 | ]); 76 | } 77 | 78 | return endpointRef; 79 | } 80 | } 81 | 82 | class _RivershelfMiddleware { 83 | final ProviderContainer _providerContainer; 84 | 85 | _RivershelfMiddleware({ 86 | ProviderContainer? parent, 87 | List overrides = const [], 88 | List? observers, 89 | }) : _providerContainer = ProviderContainer( 90 | parent: parent, 91 | overrides: overrides, 92 | observers: observers, 93 | ); 94 | 95 | _RivershelfMiddleware.fromContainer(this._providerContainer); 96 | 97 | Handler call(Handler next) => (request) async { 98 | final container = ProviderContainer( 99 | parent: _providerContainer, 100 | overrides: [shelfRequestProvider.overrideWithValue(request)], 101 | ); 102 | final endpointRef = EndpointRef(container); 103 | 104 | try { 105 | final changedRequest = request.change( 106 | context: {...request.context, rivershelfRefKey: endpointRef}, 107 | ); 108 | return await next(changedRequest); 109 | } finally { 110 | endpointRef.dispose(); 111 | container.dispose(); 112 | } 113 | }; 114 | } 115 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/test/integration/routing_endpoint_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | import 'test_helper.dart'; 7 | 8 | void main() { 9 | late ExampleServer server; 10 | 11 | setUpAll(() async { 12 | server = await ExampleServer.start(); 13 | }); 14 | 15 | tearDownAll(() async { 16 | await server.stop(); 17 | }); 18 | 19 | group('', () { 20 | test('HEAD / returns info header', () async { 21 | final response = await server.apiClient.rootRoutingHeadRoot(); 22 | expect(response.headers, containsPair('X-INFO', ['HEAD /api/v1/'])); 23 | }); 24 | 25 | test('GET / returns data', () async { 26 | final response = await server.apiClient.rootRoutingGetRoot(); 27 | expect(response, 'GET /api/v1/'); 28 | }); 29 | 30 | test('GET returns notFound', () async { 31 | final response = await server.dio.get( 32 | '/api/v1', 33 | options: Options(validateStatus: (status) => true), 34 | ); 35 | expect(response.statusCode, HttpStatus.notFound); 36 | }); 37 | 38 | test('GET /path/open returns data', () async { 39 | final response = await server.apiClient.rootRoutingGetPathOpen(); 40 | expect(response, 'GET /api/v1/path/open'); 41 | }); 42 | 43 | test('GET /path/open/ returns notFound', () async { 44 | final response = await server.dio.get( 45 | '/api/v1/path/open/', 46 | options: Options(validateStatus: (status) => true), 47 | ); 48 | expect(response.statusCode, HttpStatus.notFound); 49 | }); 50 | 51 | test('GET /path/closed/ returns data', () async { 52 | final response = await server.apiClient.rootRoutingGetPathClosed(); 53 | expect(response, 'GET /api/v1/path/closed/'); 54 | }); 55 | 56 | test('GET /path/closed returns notFound', () async { 57 | final response = await server.dio.get( 58 | '/api/v1/path/closed', 59 | options: Options(validateStatus: (status) => true), 60 | ); 61 | expect(response.statusCode, HttpStatus.notFound); 62 | }); 63 | }); 64 | 65 | group('/open', () { 66 | test('DELETE / returns data', () async { 67 | final response = await server.apiClient.openRoutingDeleteRoot(); 68 | expect(response, 'DELETE /api/v1/open/'); 69 | }); 70 | 71 | test('DELETE returns data', () async { 72 | final response = await server.dio.delete('/api/v1/open'); 73 | expect(response.statusCode, HttpStatus.ok); 74 | expect(response.data, 'DELETE /api/v1/open'); 75 | }); 76 | 77 | test('OPTIONS /path/open returns data', () async { 78 | final response = await server.apiClient.openRoutingOptionsPathOpen(); 79 | expect(response, 'OPTIONS /api/v1/open/path/open'); 80 | }); 81 | 82 | test('PATCH /path/closed/ returns data', () async { 83 | final response = await server.apiClient.openRoutingPatchPathClosed(); 84 | expect(response, 'PATCH /api/v1/open/path/closed/'); 85 | }); 86 | }); 87 | 88 | group('/closed/', () { 89 | test('POST / returns data', () async { 90 | final response = await server.apiClient.closedRoutingPostRoot(); 91 | expect(response, 'POST /api/v1/closed/'); 92 | }); 93 | 94 | test('POST returns notFound', () async { 95 | final response = await server.dio.post( 96 | '/api/v1/closed', 97 | options: Options(validateStatus: (status) => true), 98 | ); 99 | expect(response.statusCode, HttpStatus.notFound); 100 | }); 101 | 102 | test('PUT /path/open returns data', () async { 103 | final response = await server.apiClient.closedRoutingPutPathOpen(); 104 | expect(response, 'PUT /api/v1/closed/path/open'); 105 | }); 106 | 107 | test('TRACE /path/closed/ returns data', () async { 108 | final response = await server.apiClient.closedRoutingTracePathClosed(); 109 | expect(response, 'TRACE /api/v1/closed/path/closed/'); 110 | }); 111 | }); 112 | 113 | group('/', () { 114 | test('POST / returns data', () async { 115 | final response = await server.apiClient.slashRoutingPostRoot(); 116 | expect(response, 'POST /api/v1/'); 117 | }); 118 | 119 | test('POST returns notFound', () async { 120 | final response = await server.dio.post( 121 | '/api/v1', 122 | options: Options(validateStatus: (status) => true), 123 | ); 124 | expect(response.statusCode, HttpStatus.notFound); 125 | }); 126 | 127 | test('POST /slash/open returns data', () async { 128 | final response = await server.apiClient.slashRoutingPostSlashOpen(); 129 | expect(response, 'POST /api/v1/slash/open'); 130 | }); 131 | 132 | test('POST /slash/closed/ returns data', () async { 133 | final response = await server.apiClient.slashRoutingPostSlashClosed(); 134 | expect(response, 'POST /api/v1/slash/closed/'); 135 | }); 136 | }); 137 | } 138 | -------------------------------------------------------------------------------- /packages/shelf_api/lib/src/api/t_response.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:shelf/shelf.dart'; 5 | // ignore: implementation_imports for helper utility 6 | import 'package:shelf/src/util.dart' show addHeader; 7 | 8 | import 'content_types.dart'; 9 | 10 | /// A generic wrapper around [Response] used for code generation. 11 | class TResponse extends Response { 12 | /// See [Response.new] 13 | TResponse( 14 | super.statusCode, { 15 | Object? body, 16 | Map? headers, 17 | super.encoding, 18 | super.context, 19 | }) : super( 20 | body: _toBody(body, encoding), 21 | headers: _addContentTypeHeader(headers, body), 22 | ); 23 | 24 | /// See [Response.ok] 25 | TResponse.ok( 26 | T body, { 27 | Map? headers, 28 | super.encoding, 29 | super.context, 30 | }) : super.ok( 31 | _toBody(body, encoding), 32 | headers: _addContentTypeHeader(headers, body), 33 | ); 34 | 35 | /// See [Response.movedPermanently] 36 | TResponse.movedPermanently( 37 | super.location, { 38 | Object? body, 39 | Map? headers, 40 | super.encoding, 41 | super.context, 42 | }) : super.movedPermanently( 43 | body: _toBody(body, encoding), 44 | headers: _addContentTypeHeader(headers, body), 45 | ); 46 | 47 | /// See [Response.found] 48 | TResponse.found( 49 | super.location, { 50 | Object? body, 51 | Map? headers, 52 | super.encoding, 53 | super.context, 54 | }) : super.found( 55 | body: _toBody(body, encoding), 56 | headers: _addContentTypeHeader(headers, body), 57 | ); 58 | 59 | /// See [Response.seeOther] 60 | TResponse.seeOther( 61 | super.location, { 62 | Object? body, 63 | Map? headers, 64 | super.encoding, 65 | super.context, 66 | }) : super.seeOther( 67 | body: _toBody(body, encoding), 68 | headers: _addContentTypeHeader(headers, body), 69 | ); 70 | 71 | /// See [Response.notModified] 72 | TResponse.notModified({super.headers, super.context}) : super.notModified(); 73 | 74 | /// See [Response.badRequest] 75 | TResponse.badRequest({ 76 | Object? body, 77 | Map? headers, 78 | super.encoding, 79 | super.context, 80 | }) : super.badRequest( 81 | body: _toBody(body, encoding), 82 | headers: _addContentTypeHeader(headers, body), 83 | ); 84 | 85 | /// See [Response.unauthorized] 86 | TResponse.unauthorized( 87 | Object? body, { 88 | Map? headers, 89 | super.encoding, 90 | super.context, 91 | }) : super.unauthorized( 92 | _toBody(body, encoding), 93 | headers: _addContentTypeHeader(headers, body), 94 | ); 95 | 96 | /// See [Response.forbidden] 97 | TResponse.forbidden( 98 | Object? body, { 99 | Map? headers, 100 | super.encoding, 101 | super.context, 102 | }) : super.forbidden( 103 | _toBody(body, encoding), 104 | headers: _addContentTypeHeader(headers, body), 105 | ); 106 | 107 | /// See [Response.notFound] 108 | TResponse.notFound( 109 | Object? body, { 110 | Map? headers, 111 | super.encoding, 112 | super.context, 113 | }) : super.notFound( 114 | _toBody(body, encoding), 115 | headers: _addContentTypeHeader(headers, body), 116 | ); 117 | 118 | /// See [Response.internalServerError] 119 | TResponse.internalServerError({ 120 | Object? body, 121 | Map? headers, 122 | super.encoding, 123 | super.context, 124 | }) : super.internalServerError( 125 | body: _toBody(body, encoding), 126 | headers: _addContentTypeHeader(headers, body), 127 | ); 128 | 129 | @override 130 | Response change({ 131 | Map? headers, 132 | Map? context, 133 | Object? body, 134 | Encoding? encoding, 135 | }) => super.change( 136 | headers: { 137 | ...?headers, 138 | if (body != null) ...?_addContentTypeHeader(null, body), 139 | }, 140 | context: context, 141 | body: _toBody(body, encoding), 142 | ); 143 | 144 | static Object? _toBody(dynamic body, [Encoding? encoding]) => switch (body) { 145 | null => null, 146 | final String text => text, 147 | final Stream stream => stream.transform((encoding ?? utf8).encoder), 148 | final Uint8List bytes => bytes, 149 | final Stream> stream => stream, 150 | final _ => json.encode(body), 151 | }; 152 | 153 | static Map? _addContentTypeHeader( 154 | Map? headers, 155 | dynamic body, 156 | ) { 157 | final contentType = switch (body) { 158 | null => null, 159 | String() => ContentTypes.text, 160 | Stream() => ContentTypes.text, 161 | Uint8List() => ContentTypes.binary, 162 | Stream>() => ContentTypes.binary, 163 | final _ => ContentTypes.json, 164 | }; 165 | 166 | if (contentType == null) { 167 | return headers; 168 | } 169 | 170 | return addHeader(headers, 'Content-Type', contentType); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/analyzers/body_analyzer.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:analyzer/dart/element/element.dart'; 4 | import 'package:analyzer/dart/element/type.dart'; 5 | import 'package:build/build.dart'; 6 | import 'package:meta/meta.dart'; 7 | import 'package:source_gen/source_gen.dart'; 8 | import 'package:source_helper/source_helper.dart'; 9 | 10 | import '../models/endpoint_body.dart'; 11 | import '../models/opaque_type.dart'; 12 | import '../readers/body_param_reader.dart'; 13 | import '../util/type_checkers.dart'; 14 | import 'serializable_analyzer.dart'; 15 | 16 | @internal 17 | class BodyAnalyzer { 18 | final BuildStep _buildStep; 19 | final SerializableAnalyzer _serializableAnalyzer; 20 | 21 | BodyAnalyzer(this._buildStep) 22 | : _serializableAnalyzer = SerializableAnalyzer(_buildStep); 23 | 24 | Future analyzeBody(MethodElement method) async { 25 | final result = _findBodyParam(method); 26 | if (result == null) { 27 | return null; 28 | } 29 | final (param, bodyParam) = result; 30 | final paramType = param.type; 31 | 32 | if (_serializableAnalyzer.isCustom(bodyParam)) { 33 | return EndpointBody( 34 | paramType: await _serializableAnalyzer.analyzeType( 35 | param, 36 | param.type, 37 | bodyParam, 38 | ), 39 | bodyType: EndpointBodyType.json, 40 | contentTypes: bodyParam.contentTypes ?? [ContentType.json.mimeType], 41 | ); 42 | } else if (paramType.isDartCoreString) { 43 | _ensureNotNullable(paramType, method); 44 | return EndpointBody( 45 | paramType: OpaqueDartType(_buildStep, param.type), 46 | bodyType: EndpointBodyType.text, 47 | contentTypes: bodyParam.contentTypes ?? const [], 48 | ); 49 | } else if (TypeCheckers.uint8List.isAssignableFromType(paramType)) { 50 | _ensureNotNullable(paramType, method); 51 | return EndpointBody( 52 | paramType: OpaqueDartType(_buildStep, param.type), 53 | bodyType: EndpointBodyType.binary, 54 | contentTypes: bodyParam.contentTypes ?? const [], 55 | ); 56 | } else if (paramType.isDartAsyncStream) { 57 | _ensureNotNullable(paramType, method); 58 | return _analyzeStreamBody(param, paramType, bodyParam); 59 | } else { 60 | return EndpointBody( 61 | paramType: await _serializableAnalyzer.analyzeType( 62 | param, 63 | param.type, 64 | bodyParam, 65 | ), 66 | bodyType: EndpointBodyType.json, 67 | contentTypes: bodyParam.contentTypes ?? [ContentType.json.mimeType], 68 | ); 69 | } 70 | } 71 | 72 | (FormalParameterElement, BodyParamReader)? _findBodyParam( 73 | MethodElement method, 74 | ) { 75 | final lastParam = method.formalParameters 76 | .where((p) => p.isPositional) 77 | .lastOrNull; 78 | 79 | for (final param in method.formalParameters) { 80 | if (param == lastParam) { 81 | continue; 82 | } 83 | 84 | if (param.bodyParamAnnotation != null) { 85 | throw InvalidGenerationSource( 86 | 'Only the last positional parameter can be marked as body.', 87 | todo: 'Move the parameter to the end of the method', 88 | element: param, 89 | ); 90 | } 91 | } 92 | 93 | if (lastParam == null) { 94 | return null; 95 | } 96 | 97 | final bodyParam = lastParam.bodyParamAnnotation; 98 | if (bodyParam == null) { 99 | return null; 100 | } 101 | 102 | if (!lastParam.isRequiredPositional) { 103 | throw InvalidGenerationSource( 104 | 'The body parameter must be required positional.', 105 | todo: 'Turn the parameter into a non optional positional parameter', 106 | element: lastParam, 107 | ); 108 | } 109 | 110 | return (lastParam, bodyParam); 111 | } 112 | 113 | void _ensureNotNullable(DartType paramType, MethodElement method) { 114 | if (paramType.isNullableType) { 115 | throw InvalidGenerationSource( 116 | '$paramType body cannot be nullable!', 117 | todo: 'Make the type non nullable.', 118 | element: method, 119 | ); 120 | } 121 | } 122 | 123 | EndpointBody _analyzeStreamBody( 124 | FormalParameterElement param, 125 | DartType paramType, 126 | BodyParamReader bodyParam, 127 | ) { 128 | final [streamType] = paramType.typeArgumentsOf(TypeCheckers.stream)!; 129 | if (streamType.isDartCoreString && !streamType.isNullableType) { 130 | return EndpointBody( 131 | paramType: OpaqueDartType(_buildStep, param.type), 132 | bodyType: EndpointBodyType.textStream, 133 | contentTypes: bodyParam.contentTypes ?? const [], 134 | ); 135 | } else if (TypeCheckers.uint8List.isAssignableFromType(streamType) && 136 | !streamType.isNullableType) { 137 | return EndpointBody( 138 | paramType: OpaqueDartType(_buildStep, param.type), 139 | bodyType: EndpointBodyType.binaryStream, 140 | contentTypes: bodyParam.contentTypes ?? const [], 141 | ); 142 | } else { 143 | throw InvalidGenerationSource( 144 | 'Only Stream or Stream are supported as stream ' 145 | 'body types.', 146 | element: param, 147 | ); 148 | } 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/api/body_builder.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:code_builder/code_builder.dart'; 4 | import 'package:meta/meta.dart'; 5 | 6 | import '../../models/endpoint_body.dart'; 7 | import '../../util/code/if.dart'; 8 | import '../../util/code/literal_string_builder.dart'; 9 | import '../../util/constants.dart'; 10 | import '../../util/types.dart'; 11 | import '../base/code_builder.dart'; 12 | import '../base/expression_builder.dart'; 13 | import '../common/from_json_builder.dart'; 14 | 15 | @internal 16 | final class BodyBuilder { 17 | static const _bodyRef = Reference(r'$body'); 18 | 19 | final EndpointBody? _methodBody; 20 | final Reference _requestRef; 21 | 22 | const BodyBuilder(this._methodBody, this._requestRef); 23 | 24 | Code get variables => _methodBody != null 25 | ? _BodyVariableBuilder(_methodBody, _requestRef) 26 | : const Code(''); 27 | 28 | Expression? get parameter => 29 | _methodBody != null ? _BodyParamBuilder(_methodBody) : null; 30 | } 31 | 32 | final class _BodyVariableBuilder extends CodeBuilder { 33 | static const _rawBodyRef = Reference(r'$rawBody'); 34 | 35 | final EndpointBody _methodBody; 36 | final Reference _requestRef; 37 | 38 | const _BodyVariableBuilder(this._methodBody, this._requestRef); 39 | 40 | @override 41 | Iterable build() sync* { 42 | yield* _validateContentType(); 43 | 44 | final Expression bodyExpr; 45 | switch (_methodBody.bodyType) { 46 | case EndpointBodyType.text: 47 | bodyExpr = _requestRef.property('readAsString').call(const []).awaited; 48 | case EndpointBodyType.binary: 49 | bodyExpr = _requestRef 50 | .property('read') 51 | .call(const []) 52 | .property('collect') 53 | .call([_requestRef]) 54 | .awaited; 55 | case EndpointBodyType.textStream: 56 | bodyExpr = _requestRef 57 | .property('read') 58 | .call(const []) 59 | .property('cast') 60 | .call(const [], const {}, [Types.list(Types.int$)]) 61 | .property('transform') 62 | .call([Constants.utf8.property('decoder')]); 63 | case EndpointBodyType.binaryStream: 64 | bodyExpr = _requestRef 65 | .property('read') 66 | .call(const []) 67 | .asA(Types.stream(Types.uint8List)); 68 | case EndpointBodyType.json: 69 | yield* _jsonCall(); 70 | return; 71 | } 72 | 73 | yield declareFinal(BodyBuilder._bodyRef.symbol!).assign(bodyExpr).statement; 74 | } 75 | 76 | Iterable _validateContentType() sync* { 77 | if (_methodBody.contentTypes.isEmpty) { 78 | return; 79 | } 80 | 81 | yield If( 82 | literalConstList( 83 | _methodBody.contentTypes, 84 | ).property('contains').call([_requestRef.property('mimeType')]).negate(), 85 | Types.shelfResponse 86 | .newInstance( 87 | [literalNum(HttpStatus.unsupportedMediaType)], 88 | { 89 | 'body': LiteralStringBuilder() 90 | ..addTemplate( 91 | 'Expected content type to be any of ' 92 | '${_methodBody.contentTypes.map((e) => '"$e"').join(', ')} ' 93 | 'but was "%type%"', 94 | {'%type%': _requestRef.property('mimeType')}, 95 | ), 96 | }, 97 | ) 98 | .returned 99 | .statement, 100 | ); 101 | } 102 | 103 | Iterable _jsonCall() sync* { 104 | yield declareFinal(_rawBodyRef.symbol!) 105 | .assign(_requestRef.property('readAsString').call(const []).awaited) 106 | .statement; 107 | 108 | final serializableType = _methodBody.serializableParamType; 109 | final rawJsonType = FromJsonBuilder(serializableType).rawJsonType; 110 | 111 | final Expression callExpr; 112 | if (serializableType.isNullable) { 113 | callExpr = _rawBodyRef 114 | .property('isNotEmpty') 115 | .conditional( 116 | Constants.json.property('decode').call(const [_rawBodyRef]), 117 | literalNull, 118 | ) 119 | .parenthesized; 120 | } else { 121 | yield If( 122 | _rawBodyRef.property('isEmpty'), 123 | Types.shelfResponse 124 | .newInstanceNamed('badRequest', const [], { 125 | 'body': literalString('Missing required request body'), 126 | }) 127 | .returned 128 | .statement, 129 | ); 130 | callExpr = Constants.json.property('decode').call(const [_rawBodyRef]); 131 | } 132 | 133 | yield declareFinal(BodyBuilder._bodyRef.symbol!) 134 | .assign( 135 | rawJsonType == Types.dynamic$ ? callExpr : callExpr.asA(rawJsonType), 136 | ) 137 | .statement; 138 | } 139 | } 140 | 141 | final class _BodyParamBuilder extends ExpressionBuilder { 142 | final EndpointBody _methodBody; 143 | 144 | const _BodyParamBuilder(this._methodBody); 145 | 146 | @override 147 | Expression build() { 148 | if (_methodBody.bodyType != EndpointBodyType.json) { 149 | return BodyBuilder._bodyRef; 150 | } 151 | 152 | return FromJsonBuilder( 153 | _methodBody.serializableParamType, 154 | ).buildFromJson(BodyBuilder._bodyRef); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /packages/shelf_api_builder/lib/src/builders/client/method_builder.dart: -------------------------------------------------------------------------------- 1 | import 'package:code_builder/code_builder.dart'; 2 | import 'package:meta/meta.dart'; 3 | import 'package:source_helper/source_helper.dart'; 4 | 5 | import '../../models/api_class.dart'; 6 | import '../../models/endpoint.dart'; 7 | import '../../models/endpoint_body.dart'; 8 | import '../../models/endpoint_method.dart'; 9 | import '../../models/endpoint_response.dart'; 10 | import '../../util/types.dart'; 11 | import '../base/spec_builder.dart'; 12 | import 'method_body_builder.dart'; 13 | 14 | @internal 15 | final class MethodBuilder extends SpecBuilder { 16 | static const _optionsRef = Reference(r'$options'); 17 | static const _cancelTokenRef = Reference(r'$cancelToken'); 18 | static const _onSendProgressRef = Reference(r'$onSendProgress'); 19 | static const _onReceiveProgressRef = Reference(r'$onReceiveProgress'); 20 | 21 | final ApiClass _apiClass; 22 | final Endpoint _endpoint; 23 | final EndpointMethod _method; 24 | final Reference _dioRef; 25 | 26 | const MethodBuilder( 27 | this._apiClass, 28 | this._endpoint, 29 | this._method, 30 | this._dioRef, 31 | ); 32 | 33 | @override 34 | Method build() => Method( 35 | (b) => b 36 | ..name = _methodName 37 | ..returns = _returnType(false) 38 | ..modifier = _method.response.responseType.isStream 39 | ? MethodModifier.asyncStar 40 | : MethodModifier.async 41 | ..requiredParameters.addAll(_buildRequiredParameters()) 42 | ..optionalParameters.addAll(_buildOptionalParameters()) 43 | ..body = MethodBodyBuilder( 44 | _apiClass, 45 | _endpoint, 46 | _method, 47 | _dioRef, 48 | _optionsRef, 49 | [_cancelTokenRef, _onSendProgressRef, _onReceiveProgressRef], 50 | false, 51 | ), 52 | ); 53 | 54 | Method buildRaw() => Method( 55 | (b) => b 56 | ..name = '${_methodName}Raw' 57 | ..returns = _returnType(true) 58 | ..modifier = MethodModifier.async 59 | ..requiredParameters.addAll(_buildRequiredParameters()) 60 | ..optionalParameters.addAll(_buildOptionalParameters()) 61 | ..body = MethodBodyBuilder( 62 | _apiClass, 63 | _endpoint, 64 | _method, 65 | _dioRef, 66 | _optionsRef, 67 | [_cancelTokenRef, _onSendProgressRef, _onReceiveProgressRef], 68 | true, 69 | ), 70 | ); 71 | 72 | String get _methodName { 73 | var name = _endpoint.name; 74 | name = name[0].toLowerCase() + name.substring(1); 75 | if (name.endsWith('Endpoint')) { 76 | name = name.substring(0, name.length - 8); 77 | } 78 | return name + _method.name.pascal; 79 | } 80 | 81 | TypeReference _returnType(bool isRaw) { 82 | final response = _method.response; 83 | 84 | final innerType = switch (response.responseType) { 85 | EndpointResponseType.binaryStream => Types.stream(Types.uint8List), 86 | EndpointResponseType.dynamic => Types.responseBody, 87 | _ => Types.fromType(response.returnType), 88 | }; 89 | 90 | if (response.responseType.isStream) { 91 | return isRaw ? Types.future(Types.tResponseBody(innerType)) : innerType; 92 | } else { 93 | return isRaw 94 | ? Types.future(Types.tResponseBody(innerType)) 95 | : Types.future(innerType); 96 | } 97 | } 98 | 99 | Iterable _buildRequiredParameters() sync* { 100 | for (final pathParam in _method.pathParameters) { 101 | yield Parameter( 102 | (b) => b 103 | ..name = pathParam.name 104 | ..type = Types.fromType(pathParam.type), 105 | ); 106 | } 107 | if (_method.body case final EndpointBody body) { 108 | yield Parameter( 109 | (b) => b 110 | ..name = 'body' 111 | ..type = switch (body.bodyType) { 112 | EndpointBodyType.binaryStream => Types.stream( 113 | Types.list(Types.int$), 114 | ), 115 | _ => Types.fromType(body.paramType), 116 | }, 117 | ); 118 | } 119 | } 120 | 121 | Iterable _buildOptionalParameters() sync* { 122 | for (final queryParam in _method.queryParameters) { 123 | yield Parameter( 124 | (b) => b 125 | ..name = queryParam.paramName 126 | ..named = true 127 | ..required = !queryParam.isOptional 128 | ..type = 129 | (queryParam.isList 130 | ? Types.list(Types.fromType(queryParam.type)) 131 | : Types.fromType(queryParam.type)) 132 | .withNullable(queryParam.isOptional), 133 | ); 134 | } 135 | 136 | yield Parameter( 137 | (b) => b 138 | ..name = _optionsRef.symbol! 139 | ..named = true 140 | ..type = Types.options.withNullable(true), 141 | ); 142 | yield Parameter( 143 | (b) => b 144 | ..name = _cancelTokenRef.symbol! 145 | ..named = true 146 | ..type = Types.cancelToken.withNullable(true), 147 | ); 148 | yield Parameter( 149 | (b) => b 150 | ..name = _onSendProgressRef.symbol! 151 | ..named = true 152 | ..type = Types.progressCallback.withNullable(true), 153 | ); 154 | yield Parameter( 155 | (b) => b 156 | ..name = _onReceiveProgressRef.symbol! 157 | ..named = true 158 | ..type = Types.progressCallback.withNullable(true), 159 | ); 160 | } 161 | } 162 | --------------------------------------------------------------------------------