From 2d427f75548c166f14809e7db0acbe35c4748d69 Mon Sep 17 00:00:00 2001 From: iyifr Date: Sat, 31 Aug 2024 22:22:18 +0100 Subject: [PATCH 1/3] feat: refactored param routes + improved route validation regex + setResponseHeader tweaks --- CHANGELOG.md | 53 ++++++++++++++++++++---------- bin/main.dart | 26 ++++++++++++--- bin/run.dart | 2 -- lib/src/error_middleware.dart | 4 +-- lib/src/extract_path_pieces.dart | 4 +-- lib/src/h4.dart | 6 ++-- lib/src/router.dart | 1 - lib/src/trie.dart | 42 +++++++++++++---------- lib/src/trie_traverse.dart | 10 +++--- lib/utils/req_utils.dart | 15 ++++++--- lib/utils/set_response_header.dart | 5 +-- test/h4_test.dart | 36 +++++++++++++++++++- 12 files changed, 144 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6294dd2..2c8a2e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,29 +1,48 @@ -## 0.2.0 +## 0.3.0 (Minor) -- #### Minor Release +- NEW `readFormData` utility - familiar formdata parsing API - - Improved CLI +```dart +apiRouter.get("/signup", (event) async { + var formData = await readFormData(event); - - Added a new command H4 **start** which starts your app locally. + var username = formData.get('username'); + var password = formData.get('password'); - ```powershell - h4 start - ``` + // ALSO included.. + var allNames = formdata.getAll('names') // return List - _or_ + userService.signup(username, password); + event.statusCode = 201; - ```powershell - dart pub global run h4:start - ``` + return 'Hi from /api'; +}); +``` + +- NEW `getRequestIp` and `getRequestOrigin` utilities + +## 0.2.0 (Minor) + +- Added the `start` command to the CLI, `H4 start` which starts your app locally. + + ```powershell + h4 start + ``` + + _or_ + + ```powershell + dart pub global run h4:start + ``` - #### --dev flag + #### --dev flag - - Run the command with the --dev flag to restart the server when you make changes to your files + - Run the command with the --dev flag to restart the server when you make changes to your files - - ### New Utilities - - getQuery - - setResponseHeader - - getHeader +- ### New Utilities + - `getQuery` + - `setResponseHeader` + - `getHeader` ## 0.1.4 diff --git a/bin/main.dart b/bin/main.dart index b5724aa..f6d8445 100644 --- a/bin/main.dart +++ b/bin/main.dart @@ -1,4 +1,8 @@ +import 'dart:io'; + import 'package:h4/create.dart'; +import 'package:h4/utils/req_utils.dart'; +import 'package:h4/utils/set_response_header.dart'; void main() async { var app = createApp( @@ -6,6 +10,10 @@ void main() async { onRequest: (event) { // PER REQUEST local statešŸ˜» event.context["user"] = 'Ogunlepon'; + + setResponseHeader(event, + header: HttpHeaders.contentTypeHeader, + value: 'text/html; charset=utf-8'); }, afterResponse: (event) => {}, ); @@ -16,11 +24,21 @@ void main() async { app.use(router, basePath: '/'); app.use(apiRouter, basePath: '/api'); - router.get("/", (event) { - return 'Hello from /'; + router.get("/vamos/:id/bake/:cakeId", (event) { + return event.params; }); - apiRouter.get("/", (event) { - return 'Hi from /api'; + apiRouter.get("/signup", (event) async { + var formData = await readFormData(event); + + var username = formData.get('username'); + var password = formData.get('password'); + + print(getRequestIp(event)); + + // userService.signup(username, password); + event.statusCode = 201; + + return 'Hi from /api with $username, $password'; }); } diff --git a/bin/run.dart b/bin/run.dart index 0ceb484..f26ae55 100644 --- a/bin/run.dart +++ b/bin/run.dart @@ -24,8 +24,6 @@ void main(List arguments) async { print(body["map"]); var header = getHeader(event, HttpHeaders.userAgentHeader); var query = getQueryParams(event); - setResponseHeader(event, HttpHeaders.contentTypeHeader, - value: 'application/json'); return [header, body, query, event.params]; }); diff --git a/lib/src/error_middleware.dart b/lib/src/error_middleware.dart index d1c681d..f67179b 100644 --- a/lib/src/error_middleware.dart +++ b/lib/src/error_middleware.dart @@ -49,8 +49,8 @@ Function(HttpRequest) defineErrorHandler(ErrorHandler handler, event.statusCode = statusCode; - setResponseHeader(event, HttpHeaders.contentTypeHeader, - value: 'application/json'); + setResponseHeader(event, + header: HttpHeaders.contentTypeHeader, value: 'application/json'); var response = { "statusCode": statusCode, "statusMessage": "Internal server error", diff --git a/lib/src/extract_path_pieces.dart b/lib/src/extract_path_pieces.dart index f48e3c6..dfe8191 100644 --- a/lib/src/extract_path_pieces.dart +++ b/lib/src/extract_path_pieces.dart @@ -15,11 +15,11 @@ bool isValidHttpPathPattern(String pattern) { r'^(?:' r'/' r'|/(?:[\p{L}\p{N}_-]+(?:/[\p{L}\p{N}_-]+)*/?)' - r'|/(?:[\p{L}\p{N}_-]+(?:/[\p{L}\p{N}_-]+)*/)*:(?:[\p{L}\p{N}_]+)(?:/|$)' + r'|/(?:[\p{L}\p{N}_-]+/)*(?::[\p{L}\p{N}_]+)(?:/[\p{L}\p{N}_-]+)*(?:/(?:[\p{L}\p{N}_-]+/)*(?::[\p{L}\p{N}_]+)(?:/[\p{L}\p{N}_-]+)*)*/?' r'|/[\p{L}\p{N}_-]+/:[^/]+/\*\*' r'|/[\p{L}\p{N}_-]+/\*\*' r'|/[\p{L}\p{N}_-]+/\*' - r'|' + r'| ' r'|\*' r')$', unicode: true, diff --git a/lib/src/h4.dart b/lib/src/h4.dart index c14006a..584f943 100644 --- a/lib/src/h4.dart +++ b/lib/src/h4.dart @@ -186,7 +186,7 @@ class H4 { for (var key in routeStack.keys) { if (!key.startsWith('/')) { - logger.warning( + logger.severe( 'Invalid base path! - found $key - change the base path to /$key'); } @@ -264,8 +264,8 @@ NotFoundHandler return404(HttpRequest request) { return defineEventHandler( (event) { event.statusCode = 404; - setResponseHeader(event, HttpHeaders.contentTypeHeader, - value: 'application/json'); + setResponseHeader(event, + header: HttpHeaders.contentTypeHeader, value: 'application/json'); return { "statusCode": 404, "statusMessage": "Not found", diff --git a/lib/src/router.dart b/lib/src/router.dart index 8e1974e..404da39 100644 --- a/lib/src/router.dart +++ b/lib/src/router.dart @@ -54,7 +54,6 @@ class H4Router { var result = routes.search(pathChunks); result ??= routes.matchParamRoute(pathChunks); - result ??= routes.matchWildCardRoute(pathChunks); return result; diff --git a/lib/src/trie.dart b/lib/src/trie.dart index 32a19d6..df8e491 100644 --- a/lib/src/trie.dart +++ b/lib/src/trie.dart @@ -73,33 +73,39 @@ class Trie { for (String pathPiece in pathPieces) { int index = pathPieces.indexOf(pathPiece); + if (currNode?.children[pathPiece] == null) { + print(currNode); currNode?.children.forEach((key, value) { if ((key.startsWith(":") || key.startsWith("*")) && value.isLeaf) { - // Do not behave like a wildcard. Only match if the param route is an exact match. - if (pathPieces.lastOrNull == pathPiece) { - if (index == pathPieces.length - 1) { - laHandler = value.handlers; - } - } else { - // Handle weird edge case where a handler with id as a leaf is defined in route trie - var result = deepTraverse(value.children); - - if (result["leaf"] == pathPieces.lastOrNull) { - laHandler = result["handlers"]; - } + if (index == pathPieces.length - 1) { + laHandler = value.handlers; } } + // Multiple Params if (key.startsWith(":") && !value.isLeaf) { - var result = deepTraverse(value.children); + var maps = deepTraverse(value.children); + var result = maps["result"]; + var prev = maps["prev"]; - if (result["leaf"] == pathPieces.lastOrNull) { - laHandler = result["handlers"]; + if (result?["leaf"] == pathPieces.lastOrNull) { + laHandler = result?["handlers"]; + } + + if (result?["leaf"] != null) { + if (result!["leaf"].startsWith(":")) { + if (pathPieces[pathPieces.length - 2] == prev?["key"]) { + laHandler = result["handlers"]; + } + } } } }); } + if (laHandler != null) { + break; + } currNode = currNode?.children[pathPiece]; } return laHandler; @@ -144,8 +150,10 @@ class Trie { if (key.startsWith("**") && value.isLeaf) { laHandler = value.handlers; } else { - var result = deepTraverse(value.children); - laHandler = result["handlers"]; + var result = deepTraverse(value.children)["result"]; + if (result?["leaf"] == '**') { + laHandler = result?["handlers"]; + } } }); } diff --git a/lib/src/trie_traverse.dart b/lib/src/trie_traverse.dart index f55b5e2..ef1383c 100644 --- a/lib/src/trie_traverse.dart +++ b/lib/src/trie_traverse.dart @@ -32,23 +32,23 @@ class TrieNodeStack { } } -Map deepTraverse(Map nodes) { +Map> deepTraverse(Map nodes) { TrieNodeStack> stack = TrieNodeStack.from(nodes.entries); Map result = {'handlers': null, 'leaf': null}; + Map prev = {}; while (stack.isNotEmpty) { MapEntry entry = stack.pop(); String key = entry.key; TrieNode value = entry.value; - if (value.isLeaf) { result['handlers'] = value.handlers; result['leaf'] = key; - return result; - } else if (value.children.isNotEmpty) { + } else if (value.children.isNotEmpty || !value.isLeaf) { + prev['key'] = key; stack.addAll(value.children.entries); } } - return result; + return {'result': result, 'prev': prev}; } diff --git a/lib/utils/req_utils.dart b/lib/utils/req_utils.dart index a230fbd..32bb8e9 100644 --- a/lib/utils/req_utils.dart +++ b/lib/utils/req_utils.dart @@ -5,13 +5,20 @@ import 'dart:io'; import 'package:h4/create.dart'; import 'package:mime/mime.dart'; -export 'package:h4/utils/req_utils.dart' hide handleMultipartFormdata; +export 'package:h4/utils/req_utils.dart' hide handleMultipartFormdata, FormData; -getRequestIp(H4Event event) { - return event.node["value"]?.headers.value("x-forwarded-for"); +String? getRequestIp(H4Event event) { + var ip = event.node["value"]?.headers + .value("x-forwarded-for") + ?.split(',')[0] + .trim(); + + ip ??= event.node["value"]!.connectionInfo?.remoteAddress.address; + + return ip; } -getRequestUrl(H4Event event) { +String? getRequestOrigin(H4Event event) { return event.node["value"]?.headers.value(HttpHeaders.hostHeader); } diff --git a/lib/utils/set_response_header.dart b/lib/utils/set_response_header.dart index a7eef27..0abd52b 100644 --- a/lib/utils/set_response_header.dart +++ b/lib/utils/set_response_header.dart @@ -1,5 +1,6 @@ import 'package:h4/src/event.dart'; -setResponseHeader(H4Event event, String header, {required String value}) { - event.node["value"]?.response.headers.set(header, value); +setResponseHeader(H4Event event, + {required String header, required String value}) { + event.node["value"]?.response.headers.add(header, value); } diff --git a/test/h4_test.dart b/test/h4_test.dart index fcfbd26..b618d64 100644 --- a/test/h4_test.dart +++ b/test/h4_test.dart @@ -134,7 +134,7 @@ void main() { options: Options( headers: {'content-type': 'application/json'}, )); - expect(req.data, '{"hi":12}'); + expect(jsonDecode(req.data), {"hi": 12}); }); test('Correctly parses query parameters', () async { @@ -146,4 +146,38 @@ void main() { expect(jsonDecode(response.data), {"query": "iyimide", "answer": "laboss"}); }); + + test('Regex pattern for routes', () { + final regex = RegExp( + r'^(?:' + r'/' + r'|/(?:[\p{L}\p{N}_-]+(?:/[\p{L}\p{N}_-]+)*/?)' + r'|/(?:[\p{L}\p{N}_-]+/)*(?::[\p{L}\p{N}_]+)(?:/[\p{L}\p{N}_-]+)*(?:/(?:[\p{L}\p{N}_-]+/)*(?::[\p{L}\p{N}_]+)(?:/[\p{L}\p{N}_-]+)*)*/?' + r'|/[\p{L}\p{N}_-]+/:[^/]+/\*\*' + r'|/[\p{L}\p{N}_-]+/\*\*' + r'|/[\p{L}\p{N}_-]+/\*' + r'| ' + r'|\*' + r')$', + unicode: true, + ); + + final testCases = [ + '/:id/base/:studentId', + '/user/:id/posts/:postId', + '/api/:version/users/:userId/profile', + '/:id/:uuid/:hqhaId', + '/user/:id/:postId', + '/user/123', + '/user/:id', + '/user/:id/posts', + '/', + ' ', + '*', + ]; + + for (final test in testCases) { + expect(regex.hasMatch(test), true); + } + }); } From 4312f3bcc1d8432981fbfbb4b11d0958b3fb8e8f Mon Sep 17 00:00:00 2001 From: iyifr Date: Mon, 2 Sep 2024 01:27:44 +0100 Subject: [PATCH 2/3] feat: feat:param routes overhaul --- CHANGELOG.md | 7 +++-- bin/main.dart | 2 +- lib/src/trie.dart | 59 ++++++++++++++++++++++++-------------- lib/src/trie_traverse.dart | 47 ++++++++++++++++++++++++++++-- 4 files changed, 88 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c8a2e8..4390c4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## 0.3.0 (Minor) -- NEW `readFormData` utility - familiar formdata parsing API +- **NEW** `readFormData` utility - familiar formdata parsing API ```dart apiRouter.get("/signup", (event) async { @@ -19,7 +19,10 @@ apiRouter.get("/signup", (event) async { }); ``` -- NEW `getRequestIp` and `getRequestOrigin` utilities +- **NEW** `getRequestIp` and `getRequestOrigin` utilities +- **PATCHED** `H4Event` params now correctly parses more than one param in the route string. e.g + `'/user/:userId/profile/:projectId'` +- **PATCHED** all bugs in the behaviour of param routes. ## 0.2.0 (Minor) diff --git a/bin/main.dart b/bin/main.dart index f6d8445..4a3f085 100644 --- a/bin/main.dart +++ b/bin/main.dart @@ -24,7 +24,7 @@ void main() async { app.use(router, basePath: '/'); app.use(apiRouter, basePath: '/api'); - router.get("/vamos/:id/bake/:cakeId", (event) { + router.get("/vamos/:id/base/:studentId", (event) { return event.params; }); diff --git a/lib/src/trie.dart b/lib/src/trie.dart index df8e491..063a319 100644 --- a/lib/src/trie.dart +++ b/lib/src/trie.dart @@ -75,7 +75,6 @@ class Trie { int index = pathPieces.indexOf(pathPiece); if (currNode?.children[pathPiece] == null) { - print(currNode); currNode?.children.forEach((key, value) { if ((key.startsWith(":") || key.startsWith("*")) && value.isLeaf) { if (index == pathPieces.length - 1) { @@ -111,28 +110,23 @@ class Trie { return laHandler; } - Map getParams(pathPieces) { - Map params = {}; + Map getParams(List pathPieces) { + Map params = {}; TrieNode? currNode = root; - for (String pathPiece in pathPieces) { - if (currNode?.children[pathPiece] == null) { - currNode?.children.forEach((key, value) { - if (key.startsWith(":")) { - params[key.replaceAll(":", "")] = pathPiece; - } - - if (key.startsWith("*")) { - params[key.replaceAll("*", "_")] = pathPiece; - } - - if (key.startsWith("**")) { - params[key.replaceAll("**", "_")] = pathPiece; - } - }); + params = traverseTrieForSpecialChunks(currNode.children); + Map theprms = {}; + + params.forEach((key, value) { + if (value["leaf"] == true) { + theprms[key] = pathPieces.last; + } else { + List nw = value["prev"].split("/"); + var placeholderChunks = nw..removeWhere((item) => item.isEmpty); + theprms.addEntries( + matchPlaceholders(placeholderChunks, pathPieces).entries); } - currNode = currNode?.children[pathPiece]; - } - return params; + }); + return theprms; } matchWildCardRoute(List pathPieces) { @@ -162,3 +156,26 @@ class Trie { return laHandler; } } + +Map matchPlaceholders( + List placeholder, List realString) { + Map replacements = {}; + + // Iterate only up to the length of the placeholder list + for (int i = 0; i < placeholder.length; i++) { + // Check if we're still within the bounds of the realString + if (i < realString.length) { + if (placeholder[i].startsWith(':')) { + replacements[placeholder[i].replaceFirst(':', '')] = realString[i]; + } else if (placeholder[i] != realString[i]) { + // Non-placeholder elements must match exactly + return {}; + } + } else { + // realString is shorter than placeholder + return {}; + } + } + + return replacements; +} diff --git a/lib/src/trie_traverse.dart b/lib/src/trie_traverse.dart index ef1383c..6dc06e1 100644 --- a/lib/src/trie_traverse.dart +++ b/lib/src/trie_traverse.dart @@ -8,17 +8,19 @@ class TrieNodeStack { } T pop() { - if (_items.isEmpty) { - // throw StateError('Stack is empty'); - } return _items.removeLast(); } bool get isEmpty => _items.isEmpty; bool get isNotEmpty => _items.isNotEmpty; + String? get last => _items.toString(); int get length => _items.length; + T? val() { + return _items.elementAtOrNull(length - 1); + } + void addAll(Iterable items) { _items.addAll(items.toList().reversed); } @@ -52,3 +54,42 @@ Map> deepTraverse(Map nodes) { } return {'result': result, 'prev': prev}; } + +Map traverseTrieForSpecialChunks(Map nodes) { + TrieNodeStack> stack = + TrieNodeStack.from(nodes.entries); + Map result = {}; + + var prev = ''; + + while (stack.isNotEmpty) { + MapEntry entry = stack.pop(); + String key = entry.key; + TrieNode value = entry.value; + + if (key.startsWith(":")) { + if (value.isLeaf) { + key = key.replaceFirst(":", ""); + result[key] = {'leaf': true}; + } else { + prev = '$prev/$key'; + key = key.replaceFirst(":", ""); + result[key] = {'leaf': false, 'prev': prev}; + } + } else { + prev = '$prev/$key'; + } + + // if (key.startsWith("*") || key.startsWith("**")) { + // key = key.replaceFirst("**", "_"); + // result[key] = key; + // result[key] = key; + // } + + if (value.children.isNotEmpty) { + stack.addAll(value.children.entries); + } + } + + return result; +} From 8eb4c284767ecb95e4c123f630d14f0f0ca2f5f5 Mon Sep 17 00:00:00 2001 From: iyifr Date: Mon, 2 Sep 2024 01:33:00 +0100 Subject: [PATCH 3/3] changelog --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4390c4e..1c02585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ ## 0.3.0 (Minor) +- **NEW** Multiple Routers with `basePath` + +```dart + +void main() async { + var app = createApp( + port: 5173, + onRequest: (event) { + // PER REQUEST local statešŸ˜» + event.context["user"] = 'Ogunlepon'; + + setResponseHeader(event, + header: HttpHeaders.contentTypeHeader, + value: 'text/html; charset=utf-8'); + }, + afterResponse: (event) => {}, + ); + + var router = createRouter(); + var apiRouter = createRouter(); + + app.use(router, basePath: '/'); + app.use(apiRouter, basePath: '/api'); + + router.get("/vamos/:id/base/:studentId", (event) { + return event.params; + }); + + apiRouter.get("/signup", (event) async { + var formData = await readFormData(event); + + var username = formData.get('username'); + var password = formData.get('password'); + + // userService.signup(username, password); + event.statusCode = 201; + + return 'Hi from /api with $username, $password'; + }); +} +``` + - **NEW** `readFormData` utility - familiar formdata parsing API ```dart