Skip to content

Commit 53ed3de

Browse files
committed
Rewrote optional route parsing
Fixes #75
1 parent 269c8b4 commit 53ed3de

File tree

2 files changed

+166
-29
lines changed

2 files changed

+166
-29
lines changed

JLRoutes/JLRoutes.m

Lines changed: 140 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ - (NSString *)JLRoutes_fragmentQuery;
6767

6868
@end
6969

70+
@interface NSArray (Combinations)
71+
72+
- (NSArray<NSArray *> *)JLRoutes_allOrderedCombinations;
73+
74+
@end
75+
7076

7177
#pragma mark -
7278

@@ -152,33 +158,18 @@ - (void)addRoutes:(NSArray<NSString *> *)routePatterns handler:(BOOL (^)(NSDicti
152158

153159
- (void)addRoute:(NSString *)routePattern priority:(NSUInteger)priority handler:(BOOL (^)(NSDictionary<NSString *, id> *parameters))handlerBlock
154160
{
155-
// if there's a pair of parenthesis, process optionals, trim the parenthesis, put it on trimmedRoute
156-
NSString *trimmedRoute = routePattern;
161+
NSArray <NSString *> *optionalRoutePatterns = [self _optionalRoutesForPattern:routePattern];
157162

158-
// repeat until no parenthesis pair is found
159-
while ([trimmedRoute rangeOfString:@")" options:NSBackwardsSearch].location > [trimmedRoute rangeOfString:@"(" options:NSBackwardsSearch].location) {
160-
// build route with the optionals
161-
NSString *patternWithOptionals = [trimmedRoute stringByReplacingOccurrencesOfString:@"(" withString:@""];
162-
patternWithOptionals = [patternWithOptionals stringByReplacingOccurrencesOfString:@")" withString:@""];
163-
[self _registerRoute:patternWithOptionals priority:priority handler:handlerBlock];
164-
165-
// build route without optionals
166-
NSRange rangeOfLastParentheses = [trimmedRoute rangeOfString:@"(" options:NSBackwardsSearch];
167-
NSRange rangeToRemove = NSMakeRange(rangeOfLastParentheses.location, trimmedRoute.length - rangeOfLastParentheses.location);
168-
NSString *patternWithLastOptionalRemoved = [trimmedRoute stringByReplacingCharactersInRange:rangeToRemove withString:@""];
169-
170-
// remove any parenthesis for other optionals that might still be in the route
171-
NSString *patternWithoutOptionals = [patternWithLastOptionalRemoved stringByReplacingOccurrencesOfString:@"(" withString:@""];
172-
patternWithoutOptionals = [patternWithoutOptionals stringByReplacingOccurrencesOfString:@")" withString:@""];
173-
[self _registerRoute:patternWithoutOptionals priority:priority handler:handlerBlock];
174-
175-
trimmedRoute = patternWithLastOptionalRemoved;
163+
if (optionalRoutePatterns.count > 0) {
164+
// there are optional params, parse and add them
165+
for (NSString *route in optionalRoutePatterns) {
166+
[self _verboseLog:@"Automatically created optional route: %@", route];
167+
[self _registerRoute:route priority:priority handler:handlerBlock];
168+
}
169+
return;
176170
}
177171

178-
//Only register original route if trimmedRoute haven't been modified.
179-
if (trimmedRoute == routePattern) {
180-
[self _registerRoute:routePattern priority:priority handler:handlerBlock];
181-
}
172+
[self _registerRoute:routePattern priority:priority handler:handlerBlock];
182173
}
183174

184175
- (void)removeRoute:(NSString *)routePattern
@@ -249,6 +240,15 @@ - (BOOL)routeURL:(NSURL *)URL withParameters:(NSDictionary *)parameters
249240

250241
#pragma mark - Private
251242

243+
+ (instancetype)_routesControllerForURL:(NSURL *)URL
244+
{
245+
if (URL == nil) {
246+
return nil;
247+
}
248+
249+
return routeControllersMap[URL.scheme] ?: [JLRoutes globalRoutes];
250+
}
251+
252252
- (void)_registerRoute:(NSString *)routePattern priority:(NSUInteger)priority handler:(BOOL (^)(NSDictionary *parameters))handlerBlock
253253
{
254254
_JLRoute *route = [[_JLRoute alloc] init];
@@ -369,13 +369,102 @@ - (BOOL)_routeURL:(NSURL *)URL withParameters:(NSDictionary *)parameters execute
369369
return didRoute;
370370
}
371371

372-
+ (instancetype)_routesControllerForURL:(NSURL *)URL
372+
- (NSArray <NSString *> *)_optionalRoutesForPattern:(NSString *)routePattern
373+
{
374+
/* this method exists to take a route pattern that is known to contain optional params, such as:
375+
376+
/path/:thing/(/a)(/b)(/c)
377+
378+
and create the following paths:
379+
380+
/path/:thing/a/b/c
381+
/path/:thing/a/b
382+
/path/:thing/a/c
383+
/path/:thing/b/a
384+
/path/:thing/a
385+
/path/:thing/b
386+
/path/:thing/c
387+
*/
388+
389+
if ([routePattern rangeOfString:@"("].location == NSNotFound) {
390+
return nil;
391+
}
392+
393+
NSString *baseRoute = nil;
394+
NSArray *components = [self _optionalComponentsForPattern:routePattern baseRoute:&baseRoute];
395+
NSArray *routes = [self _routesForOptionalComponents:components baseRoute:baseRoute];
396+
397+
return routes;
398+
}
399+
400+
- (NSArray <NSString *> *)_optionalComponentsForPattern:(NSString *)routePattern baseRoute:(NSString **)outBaseRoute;
373401
{
374-
if (URL == nil) {
402+
if (routePattern.length == 0) {
375403
return nil;
376404
}
377405

378-
return routeControllersMap[URL.scheme] ?: [JLRoutes globalRoutes];
406+
NSMutableArray *optionalComponents = [NSMutableArray array];
407+
408+
NSScanner *scanner = [NSScanner scannerWithString:routePattern];
409+
NSString *nonOptionalRouteSubpath = nil;
410+
411+
BOOL parsedBaseRoute = NO;
412+
BOOL parseError = NO;
413+
414+
// first, we need to parse the string and find the array of optional params.
415+
// aka, take (/a)(/b)(/c) and turn it into ["/a", "/b", "/c"]
416+
while ([scanner scanUpToString:@"(" intoString:&nonOptionalRouteSubpath]) {
417+
if ([scanner isAtEnd]) {
418+
break;
419+
}
420+
421+
if (nonOptionalRouteSubpath.length > 0 && outBaseRoute != NULL && !parsedBaseRoute) {
422+
// the first 'non optional subpath' is always the base route
423+
*outBaseRoute = nonOptionalRouteSubpath;
424+
parsedBaseRoute = YES;
425+
}
426+
427+
scanner.scanLocation = scanner.scanLocation + 1;
428+
429+
NSString *component = nil;
430+
if (![scanner scanUpToString:@")" intoString:&component]) {
431+
parseError = YES;
432+
break;
433+
}
434+
435+
[optionalComponents addObject:component];
436+
}
437+
438+
if (parseError) {
439+
NSLog(@"[JLRoutes]: Parse error, unsupported route: %@", routePattern);
440+
return nil;
441+
}
442+
443+
return [optionalComponents copy];
444+
}
445+
446+
- (NSArray <NSString *> *)_routesForOptionalComponents:(NSArray <NSString *> *)optionalComponents baseRoute:(NSString *)baseRoute
447+
{
448+
if (optionalComponents.count == 0 || baseRoute.length == 0) {
449+
return nil;
450+
}
451+
452+
NSMutableArray *routes = [NSMutableArray array];
453+
454+
// generate all possible combinations of the components that could exist (taking order into account)
455+
// aka, "/path/:thing/(/a)(/b)(/c)" should never generate a route for "/path/:thing/(/b)(/a)"
456+
NSArray *combinations = [optionalComponents JLRoutes_allOrderedCombinations];
457+
458+
// generate the actual final route path strings
459+
for (NSArray *components in combinations) {
460+
NSString *path = [components componentsJoinedByString:@""];
461+
[routes addObject:[baseRoute stringByAppendingString:path]];
462+
}
463+
464+
// sort them so that the longest routes are first (since they are the most selective)
465+
[routes sortUsingSelector:@selector(length)];
466+
467+
return [routes copy];
379468
}
380469

381470
- (BOOL)_isGlobalRoutesController
@@ -480,8 +569,7 @@ - (NSDictionary *)_parametersForURL:(NSURL *)URL patternComponents:(NSArray *)pa
480569
NSString *urlDecodedVariableValue = [variableValue JLRoutes_URLDecodedString];
481570
if ([variableName length] > 0 && [urlDecodedVariableValue length] > 0) {
482571
variables[variableName] = urlDecodedVariableValue;
483-
}
484-
else {
572+
} else {
485573
NSMutableArray * newComponents = [NSMutableArray arrayWithArray:components];
486574
[newComponents addObject:@""];
487575
components = newComponents;
@@ -571,6 +659,29 @@ - (NSString *)JLRoutes_fragmentQuery
571659

572660
@end
573661

662+
@implementation NSArray (JLRoutes)
663+
664+
- (NSArray<NSArray *> *)JLRoutes_allOrderedCombinations
665+
{
666+
NSInteger length = self.count;
667+
if (length == 0) {
668+
return [NSArray arrayWithObject:[NSArray array]];
669+
}
670+
671+
id lastObject = [self lastObject];
672+
NSArray *subarray = [self subarrayWithRange:NSMakeRange(0, length - 1)];
673+
NSArray *subarrayCombinations = [subarray JLRoutes_allOrderedCombinations];
674+
NSMutableArray *combinations = [NSMutableArray arrayWithArray:subarrayCombinations];
675+
676+
for (NSArray *subarrayCombos in subarrayCombinations) {
677+
[combinations addObject:[subarrayCombos arrayByAddingObject:lastObject]];
678+
}
679+
680+
return [NSArray arrayWithArray:combinations];
681+
}
682+
683+
@end
684+
574685

575686
#pragma mark - Global Options
576687

JLRoutesTests/JLRoutesTests.m

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,32 @@ - (void)testVariableEmptyFollowedByWildcard
604604
JLValidateAnyRouteMatched();
605605
}
606606

607+
- (void)testMultipleOptionalRoutes
608+
{
609+
[[JLRoutes globalRoutes] addRoute:@"/path/:thing(/new)(/anotherpath/:anotherthing)" handler:[[self class] defaultRouteHandler]];
610+
611+
[self route:@"foo://path/abc/new/anotherpath/def"];
612+
JLValidateAnyRouteMatched();
613+
JLValidateParameter(@{@"thing": @"abc"});
614+
JLValidateParameter(@{@"anotherthing": @"def"});
615+
616+
[self route:@"foo://path/foo/anotherpath/bar"];
617+
JLValidateAnyRouteMatched();
618+
JLValidateParameter(@{@"thing": @"foo"});
619+
JLValidateParameter(@{@"anotherthing": @"bar"});
620+
621+
[self route:@"foo://path/yyy/new"];
622+
JLValidateAnyRouteMatched();
623+
JLValidateParameter(@{@"thing": @"yyy"});
624+
625+
[self route:@"foo://path/zzz"];
626+
JLValidateAnyRouteMatched();
627+
JLValidateParameter(@{@"thing": @"zzz"});
628+
629+
[self route:@"foo://path/zzz/anotherpath"];
630+
JLValidateNoLastMatch();
631+
}
632+
607633
#pragma mark - Convenience Methods
608634

609635
+ (BOOL (^)(NSDictionary *))defaultRouteHandler

0 commit comments

Comments
 (0)