diff --git a/README.md b/README.md index f191549..1f0bf16 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,10 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - spark_framework: ^1.0.0-alpha.1 + spark_framework: ^1.0.0-alpha.2 dev_dependencies: - spark_generator: ^1.0.0-alpha.1 + spark_generator: ^1.0.0-alpha.3 build_runner: ^2.4.0 ``` diff --git a/packages/spark/CHANGELOG.md b/packages/spark/CHANGELOG.md index fe18d1a..6a8f299 100644 --- a/packages/spark/CHANGELOG.md +++ b/packages/spark/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.0.0-alpha.2 + +- Added support for cookies + ## 1.0.0-alpha.1 - Initial version diff --git a/packages/spark/example/lib/spark_router.g.dart b/packages/spark/example/lib/spark_router.g.dart index f5b0867..d7a4c43 100644 --- a/packages/spark/example/lib/spark_router.g.dart +++ b/packages/spark/example/lib/spark_router.g.dart @@ -23,7 +23,8 @@ Response _$renderPageResponse( T data, PageRequest request, int statusCode, - Map headers, + Map headers, + List cookies, String? scriptName, ) { final content = page.render(data, request).toString(); @@ -44,11 +45,20 @@ Response _$renderPageResponse( return Response( statusCode, body: html, - headers: {'content-type': 'text/html; charset=utf-8', ...headers}, + headers: { + 'content-type': 'text/html; charset=utf-8', + ...headers, + if (cookies.isNotEmpty) + HttpHeaders.setCookieHeader: cookies.map((c) => c.toString()).toList(), + }, ); } -Response _$renderErrorResponse(String message, int statusCode) { +Response _$renderErrorResponse( + String message, + int statusCode, + List cookies, +) { final html = renderPage( title: 'Error $statusCode', content: @@ -63,7 +73,11 @@ Response _$renderErrorResponse(String message, int statusCode) { return Response( statusCode, body: html, - headers: {'content-type': 'text/html; charset=utf-8'}, + headers: { + 'content-type': 'text/html; charset=utf-8', + if (cookies.isNotEmpty) + HttpHeaders.setCookieHeader: cookies.map((c) => c.toString()).toList(), + }, ); } @@ -355,21 +369,40 @@ Future _$handleHomePage(Request request) async { final response = await page.loader(pageRequest); return switch (response) { - PageData(:final data, :final statusCode, :final headers) => + PageData( + :final data, + :final statusCode, + :final headers, + :final cookies, + ) => _$renderPageResponse( page, data, pageRequest, statusCode, headers, + cookies, 'home_page.dart.js', ), - PageRedirect(:final location, :final statusCode, :final headers) => - Response(statusCode, headers: {...headers, 'location': location}), - PageError(:final message, :final statusCode) => _$renderErrorResponse( - message, - statusCode, - ), + PageRedirect( + :final location, + :final statusCode, + :final headers, + :final cookies, + ) => + Response( + statusCode, + headers: { + ...headers, + 'location': location, + if (cookies.isNotEmpty) + HttpHeaders.setCookieHeader: cookies + .map((c) => c.toString()) + .toList(), + }, + ), + PageError(:final message, :final statusCode, :final cookies) => + _$renderErrorResponse(message, statusCode, cookies), }; }; diff --git a/packages/spark/lib/server.dart b/packages/spark/lib/server.dart index 379a28c..9672da0 100644 --- a/packages/spark/lib/server.dart +++ b/packages/spark/lib/server.dart @@ -74,3 +74,4 @@ export 'src/page/page.dart'; export 'src/endpoint/endpoint.dart'; export 'src/errors/errors.dart'; export 'src/http/content_type.dart'; +export 'src/http/cookie.dart'; diff --git a/packages/spark/lib/spark.dart b/packages/spark/lib/spark.dart index 536b69e..4d22329 100644 --- a/packages/spark/lib/spark.dart +++ b/packages/spark/lib/spark.dart @@ -60,3 +60,4 @@ export 'src/style/style.dart'; export 'src/style/css_types/css_types.dart'; export 'src/errors/errors.dart'; export 'src/http/content_type.dart'; +export 'src/http/cookie.dart'; diff --git a/packages/spark/lib/src/http/cookie.dart b/packages/spark/lib/src/http/cookie.dart new file mode 100644 index 0000000..2ec328b --- /dev/null +++ b/packages/spark/lib/src/http/cookie.dart @@ -0,0 +1,113 @@ +/// HTTP Cookie helper. +library; + +/// Represents the SameSite attribute of a cookie. +enum SameSite { + /// The cookie is withheld on cross-site requests. + strict('Strict'), + + /// The cookie is sent on some cross-site requests (default). + lax('Lax'), + + /// The cookie is sent on all requests. + none('None'); + + final String value; + const SameSite(this.value); +} + +/// Represents an HTTP Cookie. +class Cookie { + /// The name of the cookie. + final String name; + + /// The value of the cookie. + final String value; + + /// The expiry date of the cookie. + final DateTime? expires; + + /// The maximum age of the cookie in seconds. + final int? maxAge; + + /// The domain the cookie belongs to. + final String? domain; + + /// The path the cookie belongs to. + final String? path; + + /// Whether the cookie is secure (HTTPS only). + final bool secure; + + /// Whether the cookie is HTTP only (not accessible via JavaScript). + final bool httpOnly; + + /// The SameSite policy for the cookie. + final SameSite? sameSite; + + /// Creates a new [Cookie]. + const Cookie( + this.name, + this.value, { + this.expires, + this.maxAge, + this.domain, + this.path, + this.secure = false, + this.httpOnly = false, + this.sameSite, + }); + + /// Formats the cookie as a Set-Cookie header value. + @override + String toString() { + final buffer = StringBuffer(); + buffer.write('$name=$value'); + + if (expires != null) { + buffer.write('; Expires=${_formatHttpDate(expires!)}'); + } + if (maxAge != null) { + buffer.write('; Max-Age=$maxAge'); + } + if (domain != null) { + buffer.write('; Domain=$domain'); + } + if (path != null) { + buffer.write('; Path=$path'); + } + if (secure) { + buffer.write('; Secure'); + } + if (httpOnly) { + buffer.write('; HttpOnly'); + } + if (sameSite != null) { + buffer.write('; SameSite=${sameSite!.value}'); + } + + return buffer.toString(); + } + + // Simple HTTP date implementation to avoid dart:io dependency in shared code + String _formatHttpDate(DateTime date) { + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + final d = date.toUtc(); + return '${days[d.weekday - 1]}, ${d.day.toString().padLeft(2, '0')} ${months[d.month - 1]} ${d.year} ${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}:${d.second.toString().padLeft(2, '0')} GMT'; + } +} diff --git a/packages/spark/lib/src/page/page_response.dart b/packages/spark/lib/src/page/page_response.dart index 0af581c..b930b09 100644 --- a/packages/spark/lib/src/page/page_response.dart +++ b/packages/spark/lib/src/page/page_response.dart @@ -1,3 +1,5 @@ +import '../http/cookie.dart'; + /// Sealed class representing possible responses from a page loader. /// /// A loader can return one of: @@ -57,8 +59,16 @@ final class PageData extends PageResponse { /// Additional HTTP headers for the response. final Map headers; + /// List of cookies to set in the response. + final List cookies; + /// Creates a page data response with the given [data]. - const PageData(this.data, {this.statusCode = 200, this.headers = const {}}); + const PageData( + this.data, { + this.statusCode = 200, + this.headers = const {}, + this.cookies = const [], + }); } /// Response indicating a redirect to another URL. @@ -100,6 +110,11 @@ final class PageRedirect extends PageResponse { /// Additional HTTP headers for the response. final Map headers; + /// Creates a redirect response to the given [location]. + /// + /// List of cookies to set in the response. + final List cookies; + /// Creates a redirect response to the given [location]. /// /// Defaults to status code 302 (Found). @@ -107,26 +122,33 @@ final class PageRedirect extends PageResponse { this.location, { this.statusCode = 302, this.headers = const {}, + this.cookies = const [], }); /// Creates a permanent redirect (301 Moved Permanently). /// /// Use this when a resource has permanently moved to a new location. /// Browsers will cache this redirect. - const PageRedirect.permanent(String location) - : this(location, statusCode: 301); + const PageRedirect.permanent( + String location, { + List cookies = const [], + }) : this(location, statusCode: 301, cookies: cookies); /// Creates a temporary redirect (307 Temporary Redirect). /// /// Use this for temporary redirects that preserve the HTTP method. - const PageRedirect.temporary(String location) - : this(location, statusCode: 307); + const PageRedirect.temporary( + String location, { + List cookies = const [], + }) : this(location, statusCode: 307, cookies: cookies); /// Creates a "See Other" redirect (303 See Other). /// /// Use this to redirect after a POST request to a GET endpoint. - const PageRedirect.seeOther(String location) - : this(location, statusCode: 303); + const PageRedirect.seeOther( + String location, { + List cookies = const [], + }) : this(location, statusCode: 303, cookies: cookies); } /// Response for rendering an error page. @@ -170,21 +192,40 @@ final class PageError extends PageResponse { /// Creates an error response with the given [message]. /// /// Defaults to status code 500 (Internal Server Error). - const PageError(this.message, {this.statusCode = 500, this.data = const {}}); + /// List of cookies to set in the response. + final List cookies; + + /// Creates an error response with the given [message]. + /// + /// Defaults to status code 500 (Internal Server Error). + const PageError( + this.message, { + this.statusCode = 500, + this.data = const {}, + this.cookies = const [], + }); /// Creates a 404 Not Found error. - const PageError.notFound([String message = 'Page not found']) - : this(message, statusCode: 404); + const PageError.notFound([ + String message = 'Page not found', + List cookies = const [], + ]) : this(message, statusCode: 404, cookies: cookies); /// Creates a 403 Forbidden error. - const PageError.forbidden([String message = 'Access denied']) - : this(message, statusCode: 403); + const PageError.forbidden([ + String message = 'Access denied', + List cookies = const [], + ]) : this(message, statusCode: 403, cookies: cookies); /// Creates a 400 Bad Request error. - const PageError.badRequest([String message = 'Bad request']) - : this(message, statusCode: 400); + const PageError.badRequest([ + String message = 'Bad request', + List cookies = const [], + ]) : this(message, statusCode: 400, cookies: cookies); /// Creates a 401 Unauthorized error. - const PageError.unauthorized([String message = 'Unauthorized']) - : this(message, statusCode: 401); + const PageError.unauthorized([ + String message = 'Unauthorized', + List cookies = const [], + ]) : this(message, statusCode: 401, cookies: cookies); } diff --git a/packages/spark/pubspec.yaml b/packages/spark/pubspec.yaml index 65d10c4..d2a7ce3 100644 --- a/packages/spark/pubspec.yaml +++ b/packages/spark/pubspec.yaml @@ -1,6 +1,6 @@ name: spark_framework description: Lightweight isomorphic SSR framework for Dart with Custom Elements and Declarative Shadow DOM -version: 1.0.0-alpha.1 +version: 1.0.0-alpha.2 resolution: workspace homepage: https://spark.kleak.dev diff --git a/packages/spark/test/cookie_test.dart b/packages/spark/test/cookie_test.dart new file mode 100644 index 0000000..3f4e2b4 --- /dev/null +++ b/packages/spark/test/cookie_test.dart @@ -0,0 +1,56 @@ +import 'package:spark_framework/spark.dart'; +import 'package:test/test.dart'; + +void main() { + group('Cookie', () { + test('formats simple cookie', () { + final cookie = Cookie('session_id', '12345'); + expect(cookie.toString(), equals('session_id=12345')); + }); + + test('formats cookie with all attributes', () { + final date = DateTime.utc(2025, 1, 1, 12, 0, 0); + final cookie = Cookie( + 'user', + 'alice', + expires: date, + maxAge: 3600, + domain: 'example.com', + path: '/app', + secure: true, + httpOnly: true, + sameSite: SameSite.strict, + ); + + final str = cookie.toString(); + expect(str, contains('user=alice')); + expect(str, contains('Expires=Wed, 01 Jan 2025 12:00:00 GMT')); + expect(str, contains('Max-Age=3600')); + expect(str, contains('Domain=example.com')); + expect(str, contains('Path=/app')); + expect(str, contains('Secure')); + expect(str, contains('HttpOnly')); + expect(str, contains('SameSite=Strict')); + }); + + test('formats same site attribute', () { + expect( + Cookie('a', 'b', sameSite: SameSite.lax).toString(), + contains('SameSite=Lax'), + ); + expect( + Cookie('a', 'b', sameSite: SameSite.none).toString(), + contains('SameSite=None'), + ); + }); + + test('formats expiry date correctly', () { + final date = DateTime.utc(2025, 12, 25, 10, 30, 45); + final cookie = Cookie('c', 'v', expires: date); + expect( + cookie.toString(), + contains('Expires=Thu, 25 Dec 2025 10:30:45 GMT'), + ); + }); + }); +} diff --git a/packages/spark/test/page_response_test.dart b/packages/spark/test/page_response_test.dart new file mode 100644 index 0000000..a9fd323 --- /dev/null +++ b/packages/spark/test/page_response_test.dart @@ -0,0 +1,80 @@ +import 'package:spark_framework/spark.dart'; +import 'package:test/test.dart'; + +void main() { + group('PageResponse Cookies', () { + final cookie1 = Cookie('session', '12345', httpOnly: true); + final cookie2 = Cookie('theme', 'dark'); + + test('PageData stores cookies', () { + final response = PageData('data', cookies: [cookie1, cookie2]); + + expect(response.cookies, hasLength(2)); + expect(response.cookies, contains(cookie1)); + expect(response.cookies, contains(cookie2)); + }); + + test('PageRedirect stores cookies', () { + final response = PageRedirect('/login', cookies: [cookie1]); + + expect(response.cookies, hasLength(1)); + expect(response.cookies.first, equals(cookie1)); + }); + + test('PageRedirect.permanent stores cookies', () { + final response = PageRedirect.permanent('/new-home', cookies: [cookie2]); + + expect(response.statusCode, equals(301)); + expect(response.cookies, contains(cookie2)); + }); + + test('PageRedirect.temporary stores cookies', () { + final response = PageRedirect.temporary('/temp', cookies: [cookie1]); + + expect(response.statusCode, equals(307)); + expect(response.cookies, contains(cookie1)); + }); + + test('PageRedirect.seeOther stores cookies', () { + final response = PageRedirect.seeOther('/other', cookies: [cookie1]); + + expect(response.statusCode, equals(303)); + expect(response.cookies, contains(cookie1)); + }); + + test('PageError stores cookies', () { + final response = PageError('Error', cookies: [cookie1]); + + expect(response.cookies, hasLength(1)); + expect(response.cookies.first, equals(cookie1)); + }); + + test('PageError.notFound stores cookies', () { + final response = PageError.notFound('Not Found', [cookie1, cookie2]); + + expect(response.statusCode, equals(404)); + expect(response.cookies, hasLength(2)); + }); + + test('PageError.forbidden stores cookies', () { + final response = PageError.forbidden('No Access', [cookie1]); + + expect(response.statusCode, equals(403)); + expect(response.cookies.first, equals(cookie1)); + }); + + test('PageError.badRequest stores cookies', () { + final response = PageError.badRequest('Bad', [cookie2]); + + expect(response.statusCode, equals(400)); + expect(response.cookies.first, equals(cookie2)); + }); + + test('PageError.unauthorized stores cookies', () { + final response = PageError.unauthorized('Auth Required', [cookie1]); + + expect(response.statusCode, equals(401)); + expect(response.cookies.first, equals(cookie1)); + }); + }); +} diff --git a/packages/spark_generator/CHANGELOG.md b/packages/spark_generator/CHANGELOG.md index f09bc00..8dab7fc 100644 --- a/packages/spark_generator/CHANGELOG.md +++ b/packages/spark_generator/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.0.0-alpha.3 + +- Added support for cookies + ## 1.0.0-alpha.2 - Fixed primitives types (DartTime) to return text/plain instead of application/json in the ISO8601 format diff --git a/packages/spark_generator/lib/src/page_generator.dart b/packages/spark_generator/lib/src/page_generator.dart index 0fdca6f..b3b9844 100644 --- a/packages/spark_generator/lib/src/page_generator.dart +++ b/packages/spark_generator/lib/src/page_generator.dart @@ -115,19 +115,23 @@ class PageGenerator extends GeneratorForAnnotation { scriptName = 'null'; } buffer.writeln( - " PageData(:final data, :final statusCode, :final headers) =>", + " PageData(:final data, :final statusCode, :final headers, :final cookies) =>", ); buffer.writeln( - " _\$renderPageResponse(page, data, pageRequest, statusCode, headers, $scriptName),", + " _\$renderPageResponse(page, data, pageRequest, statusCode, headers, cookies, $scriptName),", ); buffer.writeln( - ' PageRedirect(:final location, :final statusCode, :final headers) =>', + ' PageRedirect(:final location, :final statusCode, :final headers, :final cookies) =>', ); buffer.writeln( - " Response(statusCode, headers: {...headers, 'location': location}),", + " Response(statusCode, headers: {...headers, 'location': location, if (cookies.isNotEmpty) HttpHeaders.setCookieHeader: cookies.map((c) => c.toString()).toList()}),", + ); + buffer.writeln( + ' PageError(:final message, :final statusCode, :final cookies) =>', + ); + buffer.writeln( + ' _\$renderErrorResponse(message, statusCode, cookies),', ); - buffer.writeln(' PageError(:final message, :final statusCode) =>'); - buffer.writeln(' _\$renderErrorResponse(message, statusCode),'); buffer.writeln(' };'); buffer.writeln(' };'); buffer.writeln(); diff --git a/packages/spark_generator/lib/src/router_builder.dart b/packages/spark_generator/lib/src/router_builder.dart index c102d29..f931c45 100644 --- a/packages/spark_generator/lib/src/router_builder.dart +++ b/packages/spark_generator/lib/src/router_builder.dart @@ -203,7 +203,8 @@ class RouterBuilder implements Builder { buffer.writeln(' T data,'); buffer.writeln(' PageRequest request,'); buffer.writeln(' int statusCode,'); - buffer.writeln(' Map headers,'); + buffer.writeln(' Map headers,'); + buffer.writeln(' List cookies,'); buffer.writeln(' String? scriptName,'); buffer.writeln(') {'); buffer.writeln(' final content = page.render(data, request).toString();'); @@ -227,12 +228,15 @@ class RouterBuilder implements Builder { buffer.writeln(' headers: {'); buffer.writeln(" 'content-type': 'text/html; charset=utf-8',"); buffer.writeln(' ...headers,'); + buffer.writeln( + " if (cookies.isNotEmpty) HttpHeaders.setCookieHeader: cookies.map((c) => c.toString()).toList(),", + ); buffer.writeln(' },'); buffer.writeln(' );'); buffer.writeln('}'); buffer.writeln(); buffer.writeln( - 'Response _\$renderErrorResponse(String message, int statusCode) {', + 'Response _\$renderErrorResponse(String message, int statusCode, List cookies) {', ); buffer.writeln(' final html = renderPage('); buffer.writeln(" title: 'Error \$statusCode',"); @@ -247,9 +251,12 @@ class RouterBuilder implements Builder { buffer.writeln(' return Response('); buffer.writeln(' statusCode,'); buffer.writeln(' body: html,'); + buffer.writeln(' headers: {'); + buffer.writeln(" 'content-type': 'text/html; charset=utf-8',"); buffer.writeln( - " headers: {'content-type': 'text/html; charset=utf-8'},", + " if (cookies.isNotEmpty) HttpHeaders.setCookieHeader: cookies.map((c) => c.toString()).toList(),", ); + buffer.writeln(' },'); buffer.writeln(' );'); buffer.writeln('}'); buffer.writeln(); diff --git a/packages/spark_generator/pubspec.yaml b/packages/spark_generator/pubspec.yaml index 0800ee0..ade582e 100644 --- a/packages/spark_generator/pubspec.yaml +++ b/packages/spark_generator/pubspec.yaml @@ -1,13 +1,12 @@ name: spark_generator description: Code generator for Spark Framework pages and components -version: 1.0.0-alpha.2 +version: 1.0.0-alpha.3 resolution: workspace homepage: https://spark.kleak.dev repository: https://github.com/KLEAK-Development/spark issue_tracker: https://github.com/KLEAK-Development/spark/issues - environment: sdk: ^3.10.0 @@ -15,7 +14,7 @@ dependencies: build: ^4.0.4 source_gen: ^4.1.2 analyzer: ^10.0.1 - spark_framework: ^1.0.0-alpha.1 + spark_framework: ^1.0.0-alpha.2 code_builder: ^4.11.1 glob: ^2.1.3 path: ^1.9.1 diff --git a/packages/spark_generator/test/page_generator_test.dart b/packages/spark_generator/test/page_generator_test.dart index 0027669..b0bb277 100644 --- a/packages/spark_generator/test/page_generator_test.dart +++ b/packages/spark_generator/test/page_generator_test.dart @@ -221,5 +221,84 @@ void main() { }, ); }); + test('generates cookie handling logic', () async { + await resolveSources( + { + 'spark|lib/src/annotations/page.dart': ''' + class Page { + final String path; + final List methods; + const Page({required this.path, this.methods = const ['GET']}); + } + ''', + 'spark|lib/src/page/spark_page.dart': ''' + abstract class SparkPage { + List get middleware => []; + Future loader(dynamic request); + dynamic render(dynamic data); + dynamic get components => []; + } + ''', + 'spark|lib/spark.dart': ''' + library spark; + export 'src/annotations/page.dart'; + export 'src/page/spark_page.dart'; + ''', + 'a|lib/cookie_page.dart': ''' + library a; + import 'package:spark/spark.dart'; + + @Page(path: '/cookies') + class CookiePage extends SparkPage { + } + ''', + }, + (resolver) async { + final libraryElement = await resolver.libraryFor( + AssetId('a', 'lib/cookie_page.dart'), + ); + final cookiePage = libraryElement.children + .whereType() + .firstWhere((e) => e.name == 'CookiePage'); + + final annotations = cookiePage.metadata.annotations; + final annotation = annotations.firstWhere((a) { + final element = a.element; + final enclosing = element?.enclosingElement; + return enclosing?.name == 'Page'; + }); + + final constantReader = ConstantReader( + annotation.computeConstantValue(), + ); + + final generator = PageGenerator(); + final output = generator.generateForAnnotatedElement( + cookiePage, + constantReader, + SimpleBuildStep(AssetId('a', 'lib/cookie_page.dart')), + ); + + // Verify destructuring of cookies + expect(output, contains(':final cookies')); + + // Verify passing cookies to render helper + expect( + output, + contains( + '_\$renderPageResponse(page, data, pageRequest, statusCode, headers, cookies,', + ), + ); + + // Verify setting cookie header for redirects + expect( + output, + contains( + 'HttpHeaders.setCookieHeader: cookies.map((c) => c.toString()).toList()', + ), + ); + }, + ); + }); }); }