Skip to content

Commit

Permalink
Merge pull request #238 from Baltazore/new-ember-data
Browse files Browse the repository at this point in the history
Update the Super Rentals Tutorial to use the Request Service
  • Loading branch information
IgnaceMaes authored Jul 6, 2024
2 parents e040bb1 + 2929b95 commit f766728
Show file tree
Hide file tree
Showing 6 changed files with 93 additions and 50 deletions.
4 changes: 3 additions & 1 deletion .local.dic
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,6 @@ untracked
Voilà
voilà
yay
EmberData
EmberData
RequestManager
centric
6 changes: 3 additions & 3 deletions src/assets/downloads/data/api/rentals.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"data": [
{
"type": "rentals",
"type": "rental",
"id": "grand-old-mansion",
"attributes": {
"title": "Grand Old Mansion",
Expand All @@ -18,7 +18,7 @@
}
},
{
"type": "rentals",
"type": "rental",
"id": "urban-living",
"attributes": {
"title": "Urban Living",
Expand All @@ -35,7 +35,7 @@
}
},
{
"type": "rentals",
"type": "rental",
"id": "downtown-charm",
"attributes": {
"title": "Downtown Charm",
Expand Down
2 changes: 1 addition & 1 deletion src/assets/downloads/data/api/rentals/downtown-charm.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"data": {
"type": "rentals",
"type": "rental",
"id": "downtown-charm",
"attributes": {
"title": "Downtown Charm",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"data": {
"type": "rentals",
"type": "rental",
"id": "grand-old-mansion",
"attributes": {
"title": "Grand Old Mansion",
Expand Down
2 changes: 1 addition & 1 deletion src/assets/downloads/data/api/rentals/urban-living.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"data": {
"type": "rentals",
"type": "rental",
"id": "urban-living",
"attributes": {
"title": "Urban Living",
Expand Down
127 changes: 84 additions & 43 deletions src/markdown/tutorial/part-2/11-ember-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,12 @@ In this chapter, we will work on removing some code duplication in our route han
![The Super Rentals app by the end of the chapter](/images/tutorial/part-2/ember-data/[email protected])

During this refactor, you will learn about:

* EmberData models
* Testing models
* Loading models in routes
* The EmberData store
* Working with adapters and serializers

```run:file:create hidden=true cwd=super-rentals filename=app/adapters/application.js
export { default } from '@ember-data/adapter/json-api';
```
* The EmberData Store and RequestManager
* Working with Request builders and handlers

## What is EmberData?

Expand All @@ -39,6 +36,10 @@ Fortunately, we're not going to do any of that. As it turns out, there's a much

There's a lot to learn about EmberData, but let's start by uncovering features that help with our immediate problem.

> Zoey says...
>
> RequestManager is available starting with the EmberData 4.12 LTS release. EmberData works with multiple versions of Ember, please refer to the Compatibility section of the [EmberData README](https://github.com/emberjs/data/blob/main/README.md#compatibility) while doing your application upgrade.
## EmberData Models

EmberData is built around the idea of organizing your app's data into *[model objects](../../../models/defining-models/)*. These objects represent units of information that our application presents to the user. For example, the rental property data we have been working with would be a good candidate.
Expand Down Expand Up @@ -86,7 +87,6 @@ Attributes declared with the `@attr` decorator work with the auto-track feature
```run:command hidden=true cwd=super-rentals
ember test --path dist
git add app/models/rental.js
git add app/adapters/application.js
```

## Testing Models
Expand Down Expand Up @@ -175,11 +175,12 @@ wait #qunit-banner.qunit-pass
Alright, now that we have our model set up, it's time to refactor our route handlers to use EmberData and remove the duplication!
```run:file:patch lang=js cwd=super-rentals filename=app/routes/index.js
@@ -1,22 +1,9 @@
@@ -1,22 +1,11 @@
import Route from '@ember/routing/route';
-
-const COMMUNITY_CATEGORIES = ['Condo', 'Townhouse', 'Apartment'];
+import { service } from '@ember/service';
+import { query } from '@ember-data/json-api/request';

export default class IndexRoute extends Route {
- async model() {
Expand All @@ -200,16 +201,18 @@ Alright, now that we have our model set up, it's time to refactor our route hand
- return { id, type, ...attributes };
- });
+ async model() {
+ return this.store.findAll('rental');
+ const { content } = await this.store.request(query('rental'));
+ return content.data;
}
```
```run:file:patch lang=js cwd=super-rentals filename=app/routes/rental.js
@@ -1,20 +1,9 @@
@@ -1,20 +1,13 @@
import Route from '@ember/routing/route';
-
-const COMMUNITY_CATEGORIES = ['Condo', 'Townhouse', 'Apartment'];
+import { service } from '@ember/service';
+import { findRecord } from '@ember-data/json-api/request';

export default class RentalRoute extends Route {
- async model(params) {
Expand All @@ -228,17 +231,20 @@ Alright, now that we have our model set up, it's time to refactor our route hand
-
- return { id, type, ...attributes };
+ async model(params) {
+ return this.store.findRecord('rental', params.rental_id);
+ const { content } = await this.store.request(
+ findRecord('rental', params.rental_id),
+ );
+ return content.data;
}
```
Wow... that removed a lot of code! This is all possible thanks to the power of conventions!
## The EmberData Store
As mentioned above, EmberData provides a `store` service, which we can inject into our route using the `@service store;` declaration, making the EmberData store available as `this.store`. It provides the `find` and `findAll` methods for loading records. Specifically, the [`findRecord` method](../../../models/finding-records/#toc_retrieving-a-single-record) takes a model type (`rental` in our case) and a model ID (for us, that would be `params.rental_id` from the URL) as arguments and fetches a single record from the store. On the other hand, the [`findAll` method](../../../models/finding-records/#toc_retrieving-multiple-records) takes the model type as an argument and fetches all records of that type from the store.
As mentioned above, EmberData provides a `store` service, which we can inject into our route using the `@service store;` declaration, making the EmberData store available as `this.store`. It provides the `request` method for making fetch requests using `RequestManager`. As its name implies: the `RequestManager` is request centric. Instead of answering questions about specific records or types of records, we ask it about the status of a specific request. To initiate a request, we use the `request` method on the store, passing in a request object. The request object is created using builders from `@ember-data/json-api/request`. Specifically, the [`findRecord` builder](../../../models/finding-records/#toc_retrieving-a-single-record) takes a model type (`rental` in our case) and a model ID (for us, that would be `params.rental_id` from the URL) as arguments and builds fetch options for a single record. On the other hand, the [`query` builder](../../../models/finding-records/#toc_retrieving-multiple-records) takes the model type as an argument and builds fetch options to query for all records of that type.
The EmberData store acts as a kind of intermediary between our app and the server; it does many important things, including caching the responses that were fetched from the server. If we request some records (instances of model classes) that we had *already* fetched from the server in the past, EmberData's store ensures that we can access the records immediately, without having to fetch them again unnecessarily and wait for the server to respond. But, if we don't already have that response cached in our store, then it will go off and fetches it from the server. Pretty nice, right?
EmberData can do many things, and in default setup it provides caching. EmberData's store caches server responses, allowing instant access to previously fetched data. If the data is already cached, you don't need to wait for the server to respond again. If not, the store fetches it for you.
That's a lot of theory, but is this going to work in our app? Let's run the tests and find out!
Expand All @@ -251,60 +257,95 @@ Darn, there were a couple of failing tests! At the same time, it's great that we
Looking at the failure messages, the problem appears to be that the store went to the wrong URLs when fetching data from the server, resulting in some 404 errors. Specifically:
* When performing the `findAll('rental')` query, it requested the data from `/rentals`, instead of `/api/rentals.json`.
* When performing the `find('rental', 'grand-old-mansion')` query, it requested the data from `/rentals/grand-old-mansion`, instead of `/api/rentals/grand-old-mansion.json`.
* When building the `query('rental')` request, the resulted `url` in request options was `/rentals`, instead of `/api/rentals.json`.
* When building the `findRecord('rental', 'grand-old-mansion')` request, the resulted `url` in request options was `/rentals/grand-old-mansion`, instead of `/api/rentals/grand-old-mansion.json`.
Hm, okay, so we have to teach EmberData to fetch data from the correct location. But how does EmberData know how to fetch data from our server in the first place?
## Working with Adapters and Serializers
## Working with Request builders and Handlers
EmberData uses an *[adapter](../../../models/customizing-adapters/)* and *[serializer](../../../models/customizing-serializers/)* architecture. Adapters deal with *how* and *where* EmberData should fetch data from your servers, such as whether to use HTTP, HTTPS, WebSockets or local storage, as well as the URLs, headers and parameters to use for these requests. On the other hand, serializers are in charge of converting the data returned by the server into a format EmberData can understand.
Let's start customizing the things that didn't work for us by default. Specifically:
The idea is that, provided that your backend exposes a *consistent* protocol and interchange format to access its data, we can write a single adapter-serializer pair to handle all data fetches for the entire application.
* Our resource URLs have an extra `/api` *namespace* prefix.
* Our resource URLs have a `.json` extension at the end.
As it turns out, JSON:API just happens to be EmberData's default data protocol and interchange format. Out of the box, EmberData provides a default JSON:API adapter and serializer. This is great news for us, since that is also what our server has implemented. What a wonderful coincidence!
The first thing we want to do is have our builder respect a configurable default host and/or namespace. Adding a namespace prefix happens to be pretty common across Ember apps, so EmberData provides a global config mechanism for host and namespace. Typically you will want to do this either in your store file or app file.
However, as mentioned above, there are some minor differences between how our server works and EmberData's default assumptions. We can customize the default behavior by defining our own adapter and serializer:
```run:file:patch lang=js cwd=super-rentals filename=app/app.js
@@ -2,6 +2,11 @@
import Application from '@ember/application';
import Resolver from 'ember-resolver';
import loadInitializers from 'ember-load-initializers';
import config from 'super-rentals/config/environment';
+import { setBuildURLConfig } from '@ember-data/request-utils';
+
+setBuildURLConfig({
+ namespace: 'api',
+});

```run:file:create lang=js cwd=super-rentals filename=app/adapters/application.js
import JSONAPIAdapter from '@ember-data/adapter/json-api';
export default class App extends Application {
modulePrefix = config.modulePrefix;
```
export default class ApplicationAdapter extends JSONAPIAdapter {
namespace = 'api';
Adding the `.json` extension is a bit less common, and doesn't have a declarative configuration API of its own. We could just modify request options directly in place of use, but that would be a bit messy. Instead, let's create a handler to do this for us.
buildURL(...args) {
return `${super.buildURL(...args)}.json`;
}
}
```run:file:create cwd=super-rentals filename=app/utils/handlers.js
export const JsonSuffixHandler = {
request(context, next) {
const { request } = context;
const updatedRequest = Object.assign({}, request, {
url: request.url + '.json',
});

return next(updatedRequest);
},
};
```
```run:file:create lang=js cwd=super-rentals filename=app/serializers/application.js
import JSONAPISerializer from '@ember-data/serializer/json-api';
As you can see, the handler appends `.json` to the URL of each request. Pretty simple, right? Then it calls the `next` function with the modified copy of the request object (because it is immutable). This is how we can chain multiple handlers together to build up a request.
export default class ApplicationSerializer extends JSONAPISerializer {}
```
The next step that we need to do, is to configure `RequestManager` to use this handler. Let's create the request-manager service.
By convention, adapters are located at `app/adapters`. Furthermore, the adapter named `application` is called the *application adapter*, which will be used to fetch data for all models in our app.
```run:file:create cwd=super-rentals filename=app/services/request-manager.js
import BaseRequestManager from '@ember-data/request';
import Fetch from '@ember-data/request/fetch';
import { JsonSuffixHandler } from 'super-rentals/utils/handlers';

Inside this newly created file, we defined an `ApplicationAdapter` class, inheriting from the built-in [`JSONAPIAdapter`](https://api.emberjs.com/ember-data/release/classes/JSONAPIAdapter). This allows us to inherit all the default JSON:API functionalities, while customizing the things that didn't work for us by default. Specifically:
export default class RequestManager extends BaseRequestManager {
constructor(args) {
super(args);

* Our resource URLs have an extra `/api` *namespace* prefix.
* Our resource URLs have a `.json` extension at the end.
this.use([JsonSuffixHandler, Fetch]);
}
}
```
Adding a namespace prefix happens to be pretty common across Ember apps, so the `JSONAPIAdapter` has an API to do just that. All we need to do is to set the `namespace` property to the prefix we want, which is `api` in our case.
Notice that we are using the `JsonSuffixHandler` we created earlier. We also use the `Fetch` handler, which is a built-in handler that makes the actual fetch request. The `use` method is used to add handlers to the request manager. The order in which handlers are added is important, as they will be executed in the order they were added.
Adding the `.json` extension is a bit less common, and doesn't have a declarative configuration API of its own. Instead, we will need to *[override][TODO: link to override]* EmberData's [`buildURL`](https://api.emberjs.com/ember-data/release/classes/JSONAPIAdapter/methods/buildURL?anchor=buildURL) method. Inside of `buildURL`, we will call `super.buildURL(...args)` to invoke the `JSONAPIAdapter` default implementation of `buildURL`. This will give us the URL that the adapter *would have built*, which would be something like `/api/rentals` and `/api/rentals/grand-old-mansion` after configuring the `namespace` above. All we have to do is to append `.json` to this URL and return it.
Lastly, let's update our `store` service to use the new `RequestManager` we created.
Similarly, serializers are located at `app/serializers`. Adapters and serializers are always added together as a pair. We added an `application` adapter, so we also added a corresponding serializer to go with it as well. Since the JSON data returned by our server is JSON:API-compliant, the default [`JSONAPISerializer`](https://api.emberjs.com/ember-data/release/classes/JSONAPISerializer) work just fine for us without further customization.
```run:file:patch lang=js cwd=super-rentals filename=app/services/store.js
@@ -1 +1,7 @@
-export { default } from 'ember-data/store';
+// eslint-disable-next-line ember/use-ember-data-rfc-395-imports
+import BaseStore from 'ember-data/store';
+import { service } from '@ember/service';
+
+export default class Store extends BaseStore {
+ @service requestManager;
+}
```
With our adapter and serializer in place, all our tests should pass again.
With our new EmberData configuration in place, all our tests should pass again.
```run:command hidden=true cwd=super-rentals
ember test --path dist
git add app/app.js
git add app/routes/index.js
git add app/routes/rental.js
git add app/adapters/application.js
git add app/serializers/application.js
git add app/services/request-manager.js
git add app/services/store.js
git add app/utils/handlers.js
```
```run:screenshot width=1024 height=1024 retina=true filename=pass-2.png alt="Once again, all the tests are passing again!"
Expand All @@ -324,7 +365,7 @@ visit http://localhost:4200/rentals/grand-old-mansion?deterministic
wait .rental.detailed
```
EmberData offers many, many features (like managing the *relationships* between different models) and there's a lot more we can learn about it. For example, if your backend's have some inconsistencies across different endpoints, EmberData allows you to define more specific, per-model adapters and serializers too! We are just scratching the surface here. If you want to learn more about EmberData, check out [its own dedicated section](../../../models/) in the guides!
EmberData offers many, many features (like managing the *relationships* between different models) and there's a lot more we can learn about it. For example, if your backend's have some inconsistencies across different endpoints, EmberData allows you to define request specific handlers and builders! We are just scratching the surface here. If you want to learn more about EmberData, check out [its own dedicated section](../../../models/) in the guides!
```run:server:stop
npm start
Expand Down

0 comments on commit f766728

Please sign in to comment.