Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

URL Manager #570

Closed
wants to merge 31 commits into from
Closed
Changes from 5 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
d40cff4
begin
NullVoxPopuli Oct 9, 2019
63b42f1
fleshing out the API
NullVoxPopuli Dec 23, 2019
0a5ef00
draft complete. need to verify existing capabilities
NullVoxPopuli Dec 26, 2019
be32f6e
minor updates
NullVoxPopuli Dec 31, 2019
dfa5815
Update 0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
52e241f
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
c1c96c5
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
f20547a
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
dfb1e31
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
900723c
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
68d32a8
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
487bbfe
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
5fe3edd
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
d4cdc5d
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
a7ed088
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
453b7a2
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
c0d093c
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
0e5b192
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
40c23a9
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
b9b80dd
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
fb2027b
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
0774eee
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
6823d79
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
ea16383
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
0764081
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
b4578bb
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
0d06b04
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
1eeaab6
Update 0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
d3aa12b
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
1cabcb2
Update text/0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
bfd243d
Update 0000-url-primitives.md
NullVoxPopuli Jan 5, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
349 changes: 349 additions & 0 deletions text/0000-url-primitives.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,349 @@
- Start Date: 2020-01-04
- Relevant Team(s): Ember.js
- RFC PR: https://github.com/emberjs/rfcs/pull/570
- Tracking: (leave this empty)

# URL Manager

Supersedes [Add queryParams to the router service](https://github.com/emberjs/rfcs/pull/380)

## Summary

An URL Manager will provide the ability to manipulate the URL --
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
both when writing to the `window.location` (serializing),
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
and reading from the `window.location` (deserializing).
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
The ultimate goal of the URL Manager will be to enable
app and addon authors to customize the use of the URL --
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
including i18n'd routes, dynamic segment slugs / aliases,
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
and alternate query params behavior -- though,
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
implementation of those specific things is outside the scope of *this* RFC.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't that something that the Location API can already handle? 🤔

Copy link
Contributor Author

@NullVoxPopuli NullVoxPopuli Jan 5, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Location api can't handle nested/array query params, which is a common ask


## Motivation

There is currently no way to alter the router's behavior around the interpretation of the URL.
This means that we are unable to provide internationalized URLs,
we cannot customize the (de)serialization of query params
(which is important because there is no standard format for query params).

These APIs will also provide an opportunity to iterate on a better default query params implementation that will enable an objectively better development experience for app devs
when interacting with query params.

If no URL Manager is configured, Ember's current URL behavior will be preserved.


NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
## Examples

The following examples of the proposed API are written in TypeScript in _userland / application-space_ to better
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
demonstrate intended API and available data.

The same naming semantics and conventions in today's router system will still apply.

All examples will have the following in common:
```ts
// router.js
import EmberRouter, { URLManager } from '@ember/routing/router';
import config from 'app-name/config/environment';

// ... Example CustomURLManager here ...
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved

export default class Router extends EmberRouter {
location = config.locationType;
rootURL = config.rootURL;
urlManager = CustomURLManager; // Defined in Examples
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
});
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved

Router.map(function() {
this.route('blogs', function() {
// index route would be the "list" of blogs
this.route('blog', { path: ':id' }, function() {
this.route('posts', function() {
// index route would be the "list" of posts
this.route('post', { path: ':id' });
})
});
})
});
```


### Naiive Query Params without Controllers
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved

This example proposes the possibility of an alternate strategy for managing query params without the need for controllers.
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
Note that this would not change or alter the existing query param behavior in any way.


NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
```ts
// app/router.js/ts
import { inject as service } from '@ember/service';
import qs from 'qs';

class CustomURLManager extends URLManager {
@service router;

fromURL(url: string): RouteInfo { // /blogs/1/posts/2?foo=bar
let [path, query] = url.split('?');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge case, but this would fail for a URL including more than one ? like: https://example.com/foo?param?otherParam

You could use the URL, however it's not natively available in IE11 and may be significantly slower than plain string processing. It could also be faster though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are multiple ? valid? wouldn't you need to escape additional ??

fwiw, I'm not proposing that these examples be actual implementation. They need tests, and there are def cases not covered

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, they are valid: https://stackoverflow.com/a/2924187/420747

Not really common though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

legit. so, whenever someone implements this, they should def not do a naive split on '?', but split on the first occurrence of ?


let routeInfo = this.router.recognize(path);
// Because query params have no standardized way of (de)serialization,
// there has been no way to transform deep objects or arrays.
// This gives control over this process, allowing existing code that hacked
// around this limitation to be deleted.
let queryParams = qs.parse(query);

return {
...routeInfo,
queryParams,
};
}

toURL(routeInfo: RouteInfo) {
let {
mapInfo: {
segments // [blogs, :blogId, posts, :postId]
},
queryParams, // { foo: 'bar' }
dynamicSegments,
} = routeInfo;

let url = segments.map(segment => dynamicSegments[segment] || segment).join('/');

let query = qs.stringify(queryParams);

return `/${url}?${query}`; // => /blogs/1/posts/2?foo=bar
}
}
```

### i18n routes
In this scenario, we may want to internationalized route segments.
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved

For example, in English, we may want a route to be `/blogs/1/posts/2`,
but in Korean, `/블로그/1/게시물/2`.

```ts
// app/router.js/ts
class CustomURLManager extends URLManager {
// current language: Korean
@service i18n;
@service router;

fromURL(url: string): RouteInfo { // /블로그/1/게시물/2?foo=bar
let [path, query] = url.split('?');
let segments = path.split('/');

// this dosen't have to be english, but it does have to match the names as
// defined in Router.map(...);
let english =
path.split('/')
.segments.map(segment => {
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
return this.i18n.lookup(`routes.from.${segment}`, 'en-us') || segment;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this imply that the en-us locale contains the mappings from all other languages' segments to the names used in the code? Shouldn't this mapping actually be part of every individual locale?

Copy link
Contributor Author

@NullVoxPopuli NullVoxPopuli Jan 5, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't this imply that the en-us locale contains the mappings from all other languages' segments to the names used in the code?

yes? maybe? depends on how you'd want to implement it

Shouldn't this mapping actually be part of every individual locale?

maybe. I don't know how molt people would want to implement this. Ember's route's are typically named in english, hence the approach here.

With this RFC, I mostly just want to enable people to play around with this, and I hope that someone does a much better job with i18n routes than I have in this example.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just wanted to note, that if used like this, it'd probably be wiser to remove the en-us in favor of the current locale.

})
.join('/');

let routeInfo = this.router.recognize(english);
let queryParams = qs.parse(query);

return {
...routeInfo,
queryParams,
};
}

toURL(routeInfo: RouteInfo) {
let {
mapInfo: {
segments // [blogs, :blogId, posts, :postId]
},
queryParams, // { foo: 'bar' }
dynamicSegments,
} = routeInfo;

let url = segments.map(segment => {
return dynamicSegments[segment] || this.i18n.t(`routes.${segment}`);
}).join('/');

let query = qs.stringify(queryParams);

return `/${url}?${query}`; // => /블로그/1/게시물/2?foo=bar
}
}


```

### Custom URL Management per-route
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
There are situations in which links and their routes don't conform to the rest of the application. For these situations, here is an example showing how manage the URL separately for those routes.
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved

```ts
// app/router.js/ts
Router.map(function() {
this.route('no-query-params', {
fromURL(url: string): RouteInfo {
let [path, query] = url.split('?');

return this.router.recognize(path);
},
toURL(routeInfo: RouteInfo, dynamicSegments: object) {
return this.router.urlFor({routeInfo, queryParams: undefined });
}
});
// this.route('posts', ...)
});

class CustomURLManager extends URLManager {
@service router;

fromURL(url: string): RouteInfo {
let [path, query] = url.split('?');
// NOTE: this method could be named anything, as long as it matches
// what is in the options / mapInfo object of the Router.map
let { fromURL } = this.router.mapInfoFrom(path)

if (fromURL) return fromURL(url);

// ...
}

toURL(routeInfo: RouteInfo, dynamicSegments: object) {
let { mapInfo: { toURL } } = routeInfo;

if (toURL) return toURL(routeInfo, dynamicSegments);

// ...
}
}
```

## Detailed design
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved

### Additions to existing Public APIs
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
1. add full list of dynamicSegments to RouteInfo so that the task of building out the map of dynamic segments to their values isn't in user-space
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
2. Restriction of Query Params on controllers / `<LinkTo />` needs to have a way to opt-out.
Today, if a query param is added to a `<LinkTo />` and that query param is not present on the target `route`'s controller, the query param is removed from the link.
Query Param allow/deny lists could be re-implemented using the Router `MapInfo` / options.
3. The router's urlFor helper function should be able to take a RouteInfo / RouteInfo should be able to be converted to an URL
- delegates to `urlManager.toURL` for `RouteInfo`
4. Static `MapInfo` object reference added to each `RouteInfo`.
5. routerService.mapInfoFrom should take: path / url / routeInfo -- uses existing recognize method


### Changes to Internals
1. `<LinkTo />` and other related router helpers use `toURL` and `fromURL` from the URL Manager
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
This allows more control over the allow/deny list behavior of query params filtering.

### Additional APIs / Behavior
1. URLManager is a container-controlled object, so that it may utilize the dependency injection system.
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
Primary need for this is to access the router service to utilize existing APIs for transforming URLs and `RouteInfo`s

### Notes
1. `MapInfo` is static or "frozen" -- it is only constructed at the time of router setup.
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
2. `MapInfo` represents the optional `object` argument of `Router.map`'s `this.route`. Empty object if not configured.
3. If needed, the URL Manager may be used from a component:
```ts
class Post extends Component {
@service router;

get urlForCurrentRoute_butTheLongWay() {
// the same as this.router.currentURL (if currentURL also had queryParams)
return this.router.urlManager.toURL(this.router.routeInfo);
}
}
```
4. `transitionTo` and `replaceWith` APIs are unaffected.

### The Default URL Manager

Once implemented, the URL behavior, by default, will function as before the URL Manager -- in that the standard router.js script:

```ts
// router.js
import EmberRouter from '@ember/routing/router';
import config from 'app-name/config/environment';

export default class Router extends EmberRouter {
location = config.locationType;
rootURL = config.rootURL;
});
NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved

Router.map(...);
```

is sufficient for maintaining backwards compatibility.

## How we teach this

> What names and terminology work best for these concepts and why? How is this
idea best presented? As a continuation of existing Ember patterns, or as a
wholly new one?

Like the component-manager, and modifier-manager,
this should be considered a low-level API that most people shouldn't need to interact with.
Addon authors may implement different URL-handling techniques and export an URL manager
for app-devs to assign in the router.js file.

Maybe after a bit of exploration (maybe of query params, specifically), a particular approach may be pulled in to Ember.

> Would the acceptance of this proposal mean the Ember guides must be
re-organized or altered? Does it change how Ember is taught to new users
at any level?

The guides don't need any changes, but the API documentation would need to be thorough.

## Drawbacks

The biggest drawback is that it would be easy to break routing in the app.
During implementation, this could be mitigated by providing a generated route unit test that
symmetrically checks that toURL and fromURL are inverses of each other for the given route's expected URL / routeInfo.

```ts
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';

module('Unit | Route | blogs/blog/posts/post', function(hooks) {
setupTest(hooks);

test('it exists', function(assert) {
let route = this.owner.lookup('route:blogs/blog/posts/post');
assert.ok(route);
});

test('urls are resolved', function(assert) {
let router = ;
let sampleUrl = '/blogs/1/posts/2';
let resultUrl = toURL(fromURL(sampleURL))

assert.equal(resultUrl, sampleUrl);

let mapInfo = router.mapInfoFor('blogs.blog.posts.post');

let sampleRouteInfo = {
mapInfo,
queryParams: {}
dynamicSegments: { blog: '1', post: '2' },
}
let resultRouteInfo = fromURL(toURL(sampleRouteInfo));

assert.deepEqual(resultRouteInfo, sampleRouteInfo);
});
});
```
Comment on lines +322 to +353
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my experience, route unit tests are almost always a very bad thing. Granted that this might change the dynamics around that somewhat, but it also seems to me that what is being tested should not usually be for individual routes but instead for (a) the custom manager class and/or (b) the extended router class.

Note that even in your test here, you're not actually testing the route, you're testing how the router handles the definition of that route.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where would a test for the url-manager live? tests/unit/url-manager.js?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @chriskrycho, we should write integration and acceptance tests for them


## Alternatives

- Builtin regex matchers for the routes
- hard to debug
- can often match on incorrect portions of the Route if not thoroughly tested

- Only adding QueryParams modifications (per RFC #380)
- not flexible enough
- forces a single query params implementation

Prior Art:
- the manager patterns from elsewhere in Ember.


NullVoxPopuli marked this conversation as resolved.
Show resolved Hide resolved
## Unresolved questions

- Should the URL Manager exist as a static config or an instantiable object per-route? The above proposal is a static config / singleton, but allowing an instance per route would allow for more varied state buckets, but could also make debugging harder as there would be an URL Manager for each route segment.

- `toURL` / `fromURL` or `serialize` / `deserialize`?
Comment on lines +368 to +372
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Lazy-loaded) engines should be considered in this RFC as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is engine support totally in addon space? I know nothing of engines? how would URL manipulation affect engines?

or is it more just that there could be multiple routers?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Engine support is in core and addon space. Routable engines partake in the routing process. They define their routing map in routes.js and are mounted in the parent routing map via mount(name: string, options?: MountOptions).

The engine routing maps are basically merged with the host routing map — it's a bit more complicated — but engines don't have their own Router instances.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah ok, then I think everything should be compat with this.

App defines the CustomURLManager, and if needed, engines could override that per route

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toURL / fromURL or serialize / deserialize?

definitely toURL / fromURL - with (de)serialize you are constantly question which direction is serialize? 😇