diff --git a/.eslintignore b/.eslintignore index e2737df8032..c2710d4b7d0 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,6 +7,8 @@ **/dist-control/ **/dist-experiment/ **/tmp/ +/packages/tracking/addon/ +/packages/request/addon/ /packages/-ember-data/docs/ /packages/tracking/addon/ diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 9f20fa4cb16..aa0189769e4 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -89,4 +89,3 @@ runs: lint-${{ github.head_ref }} lint-master lint- - diff --git a/.gitignore b/.gitignore index c5e71a2aef5..16047f85901 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ concat-stats-for dist tmp packages/tracking/addon +packages/request/addon # dependencies bower_components diff --git a/README.md b/README.md index 10e3074a398..6459e0a0cc9 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,85 @@ -EmberData -============================================================================== +

+ + +

+ + +

The lightweight reactive data library for JavaScript applications

[![Build Status](https://github.com/emberjs/data/workflows/CI/badge.svg)](https://github.com/emberjs/data/actions?workflow=CI) [![Code Climate](https://codeclimate.com/github/emberjs/data/badges/gpa.svg)](https://codeclimate.com/github/emberjs/data) [![Discord Community Server](https://img.shields.io/discord/480462759797063690.svg?logo=discord)](https://discord.gg/zT3asNS) +--- -# Overview +Wrangle your application's data management with scalable patterns for developer productivity. -`EmberData` is a lightweight reactive data library for JavaScript applications that provides composable primitives for ordering query/mutation/peek flows, managing network and cache, and reducing data for presentation. You can plug-and-play as desired for any api structure and format. +- ⚑️ Committed to Best-In-Class Performance +- 🌲 Focused on being as svelte as possible +- πŸš€ SSR Ready +- πŸ”œ Typescript Support +- 🐹 Built with β™₯️ by [Ember](https://emberjs.com) +- βš›οΈ Supports any API: `GraphQL` `JSON:API` `REST` `tRPC` ...bespoke or a mix -It was designed for robustly managing data in applications built with [Ember](https://github.com/emberjs/ember.js/) and is agnostic to the underlying persistence mechanism, so it works just as well with [JSON:API](https://jsonapi.org/) or [GraphQL](https://graphql.org/) over `HTTPS` as it does with streaming `WebSockets` or local `IndexedDB` storage. +### πŸ“– On This Page -It provides many of the features you'd find in server-side `ORM`s like `ActiveRecord`, but is designed specifically for the unique environment of `JavaScript` in the browser. +- [Overview](./#overview) + - [Architecture](./#-architecture) + - [Basic Installation](./#basic-installation) + - [Advanced Installation](./#advanced-installation) +- [Configuration](./#configuration) + - [Deprecation Stripping](./#deprecation-stripping) + - [randomUUID polyfill](./#randomuuid-polyfill) + - [Removing inspector support in production](./#removing-inspector-support-in-production) + - [Debugging](./#debugging) +- [Contributing](./#contributing) + +# Overview + +*Ember***Data** is a lightweight reactive data library for JavaScript applications that provides composable primitives for ordering query/mutation/peek flows, managing network and cache, and reducing data for presentation. -- [Usage Guide](https://guides.emberjs.com/release/models/) - [API Documentation](https://api.emberjs.com/ember-data/release) +- [Community & Help](https://emberjs.com/community) - [Contributing Guide](./CONTRIBUTING.md) +- [Usage Guide](https://guides.emberjs.com/release/models/) - [RFCs](https://github.com/emberjs/rfcs/labels/T-ember-data) -- [Community](https://emberjs.com/community) - [Team](https://emberjs.com/team) - [Blog](https://emberjs.com/blog) + +## πŸͺœ Architecture + +*Ember***Data** is both *resource* centric and *document* centric in it's approach to caching, requesting and presenting data. Your application's configuration and usage drives which is important and when. + +The `Store` is a **coordinator**. When using a `Store` you configure what cache to use, how cache data should be presented to the UI, and where it should look for requested data when it is not available in the cache. + +This coordination is handled opaquely to the nature of the requests issued and the format of the data being handled. This approach gives applications broad flexibility to configure *Ember***Data** to best suite their needs. This makes *Ember***Data** a powerful solution for applications regardless of their size and complexity. + +*Ember***Data** is designed to scale, with a religious focus on performance and asset-size to keep its footprint small but speedy while still being able to handle large complex APIs in huge data-driven applications with no additional code and no added application complexity. It's goal is to prevent applications from writing code to manage data that is difficult to maintain or reason about. + +*Ember***Data**'s power comes not from specific features, data formats, or adherence to specific API specs such as `JSON:API` `trpc` or `GraphQL`, but from solid conventions around requesting and mutating data developed over decades of experience scaling developer productivity. + + + ## Basic Installation Install using your javascript package manager of choice. For instance with [pnpm](https://pnpm.io/) ```no-highlight -pnpm add -D ember-data +pnpm add ember-data ``` `ember-data` is installed by default for new applications generated with `ember-cli`. You can check what version is installed by looking in the `devDependencies` hash of your project's [package.json](https://docs.npmjs.com/cli/v8/configuring-npm/package-json) file. @@ -38,7 +89,7 @@ not wish to use `ember-data`, remove `ember-data` from your project's `package.j ## Advanced Installation -EmberData is organized into primitives that compose together via public APIs. +*Ember***Data** is organized into primitives that compose together via public APIs. - [@ember-data/store](./packages/store) is the core and handles coordination - [@ember-data/record-data](./packages/record-data) is a resource cache for JSON:API structured data. It integrates with the store via the hook `createRecordDataFor` @@ -56,7 +107,7 @@ public APIs, other libraries or applications may provide their own implementatio ### Deprecation Stripping -EmberData allows users to opt-in and remove code that exists to support deprecated behaviors. +*Ember***Data** allows users to opt-in and remove code that exists to support deprecated behaviors. If your app has resolved all deprecations present in a given version, you may specify that version as your "compatibility" version to remove the code that supported the deprecated behavior from your app. @@ -72,7 +123,7 @@ let app = new EmberApp(defaults, { ### randomUUID polyfill -EmberData uses `UUID V4` by default to generate identifiers for new data created on the client. Identifier generation is configurable, but we also for convenience will polyfill +*Ember***Data** uses `UUID V4` by default to generate identifiers for new data created on the client. Identifier generation is configurable, but we also for convenience will polyfill the necessary feature if your browser support or deployment environment demands it. To activate this polyfill: diff --git a/docs-generator/yuidoc.json b/docs-generator/yuidoc.json index 40143b558c0..7d4b84c2d9f 100644 --- a/docs-generator/yuidoc.json +++ b/docs-generator/yuidoc.json @@ -20,7 +20,9 @@ "../packages/record-data/addon", "../packages/debug/addon", "../packages/private-build-infra/addon", - "../packages/canary-features/addon" + "../packages/canary-features/addon", + "../packages/tracking/src", + "../packages/request/src" ], "exclude": "vendor", "outdir": "../packages/-ember-data/dist/docs" diff --git a/ember-data-logo-dark.svg b/ember-data-logo-dark.svg new file mode 100644 index 00000000000..737a4aa4321 --- /dev/null +++ b/ember-data-logo-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/ember-data-logo-light.svg b/ember-data-logo-light.svg new file mode 100644 index 00000000000..58ac3d4e544 --- /dev/null +++ b/ember-data-logo-light.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/package.json b/package.json index 2992d1e1a6d..3c1359fdc12 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,12 @@ "url": "git+ssh://git@github.com:emberjs/data.git" }, "scripts": { - "build-v2-addons": "pnpm --filter @ember-data/tracking build", + "build-v2-addons": "pnpm --filter @ember-data/tracking --filter @ember-data/request build", "build:docs": "mkdir -p packages/-ember-data/dist && cd ./docs-generator && node ./compile-docs.js", "lint:js": "eslint --cache --ext=js,ts .", "preinstall": "npx only-allow pnpm", "problems": "tsc -p tsconfig.json --noEmit --pretty false", - "test": "pnpm --filter main-test-app --filter graph-test-app run test", + "test": "pnpm --filter main-test-app --filter graph-test-app --filter request-test-app run test", "test:production": "pnpm --filter main-test-app --filter graph-test-app run test -e production", "test:try-one": "pnpm --filter main-test-app run test:try-one", "test:docs": "pnpm build:docs && pnpm --filter docs-tests test", diff --git a/packages/-ember-data/addon/index.js b/packages/-ember-data/addon/index.js index d8e1d8d225a..033298cdc9c 100644 --- a/packages/-ember-data/addon/index.js +++ b/packages/-ember-data/addon/index.js @@ -1,13 +1,75 @@ /** - # Overview +

+ +

-`EmberData` is a lightweight reactive data library for javascript applications that provides composable primitives for ordering query/mutation/peek flows, managing network and cache, and reducing data for presentation that you can plug-and-play as desired for any api structure and format. +

The lightweight reactive data library for JavaScript applications

-It was designed for robustly managing data in applications built with [Ember](https://github.com/emberjs/ember.js/) and is agnostic to the underlying persistence mechanism, so it works just as well with [JSON:API](https://jsonapi.org/) or [GraphQL](https://graphql.org/) over `HTTPS` as it does with streaming `WebSockets` or local `IndexedDB` storage. +--- -It provides many of the facilities you'd find in server-side `ORM`s like `ActiveRecord`, but is designed specifically for the unique environment of `JavaScript` in the browser. +Wrangle your application's data management with scalable patterns for developer productivity. -EmberData is organized into primitives that compose together via public APIs. +- ⚑️ Committed to Best-In-Class Performance +- 🌲 Focused on being as svelte as possible +- πŸš€ SSR Ready +- πŸ”œ Typescript Support +- 🐹 Built with β™₯️ by [Ember](https://emberjs.com) +- βš›οΈ Supports any API: `GraphQL` `JSON:API` `REST` `tRPC` ...bespoke or a mix + +### πŸ“– On This Page + +- [Overview](./#overview) + - [Architecture](#πŸͺœ-architecture) + - [Basic Installation](#basic-installation) + - [Advanced Installation](#advanced-installation) +- [Configuration](#configuration) + - [Deprecation Stripping](#deprecation-stripping) + - [randomUUID polyfill](#randomuuid-polyfill) + - [Removing inspector support in production](#removing-inspector-support-in-production) + - [Debugging](#debugging) + + +# Overview + +*Ember*‍**Data** is a lightweight reactive data library for JavaScript applications that provides composable primitives for ordering query/mutation/peek flows, managing network and cache, and reducing data for presentation. + +## πŸͺœ Architecture + +The core of *Ember*‍**Data** is the `Store`, which coordinates interaction between your application, the `Cache`, and sources of data (such as your `API` or a local persistence layer). +Optionally, the Store can be configured to hydrate the response data into rich presentation classes. + +*Ember*‍**Data** is both resource centric and document centric in it's approach to caching, requesting and presenting data. Your application's configuration and usage drives which is important and when. + +The `Store` is a **coordinator**. When using a `Store` you configure what cache to use, how cache data should be presented to the UI, and where it should look for requested data when it is not available in the cache. + +This coordination is handled opaquely to the nature of the requests issued and the format of the data being handled. This approach gives applications broad flexibility to configure *Ember*‍**Data** to best suite their needs. This makes *Ember*‍**Data** a powerful solution for applications regardless of their size and complexity. + +*Ember*‍**Data** is designed to scale, with a religious focus on performance and asset-size to keep its footprint small but speedy while still being able to handle large complex APIs in huge data-driven applications with no additional code and no added application complexity. It's goal is to prevent applications from writing code to manage data that is difficult to maintain or reason about. + +*Ember*‍**Data**'s power comes not from specific features, data formats, or adherence to specific API specs such as `JSON:API` `trpc` or `GraphQL`, but from solid conventions around requesting and mutating data developed over decades of experience scaling developer productivity. + +## Basic Installation + +Install using your javascript package manager of choice. For instance with [pnpm](https://pnpm.io/) + +```no-highlight +pnpm add ember-data +``` + +`ember-data` is installed by default for new applications generated with `ember-cli`. You can check what version is installed by looking in the `devDependencies` hash of your project's [package.json](https://docs.npmjs.com/cli/v8/configuring-npm/package-json) file. + +If you have generated a new `Ember` application using `ember-cli` but do +not wish to use `ember-data`, remove `ember-data` from your project's `package.json` file and run your package manager's install command to update your lockfile. + +## Advanced Installation + +*Ember*‍**Data** is organized into primitives that compose together via public APIs. - [@ember-data/store](/ember-data/release/modules/@ember-data%2Fstore) is the core and handles coordination - [@ember-data/record-data](/ember-data/release/modules/@ember-data%2Frecord-data) is a resource cache for JSON:API structured data. It integrates with the store via the hook `createRecordDataFor` @@ -25,7 +87,7 @@ public APIs, other libraries or applications may provide their own implementatio ### Deprecation Stripping -EmberData allows users to opt-in and remove code that exists to support deprecated behaviors. +*Ember*‍**Data** allows users to opt-in and remove code that exists to support deprecated behaviors. If your app has resolved all deprecations present in a given version, you may specify that version as your "compatibility" version to remove the code that supported the deprecated behavior from your app. @@ -41,7 +103,7 @@ let app = new EmberApp(defaults, { ### randomUUID polyfill -EmberData uses `UUID V4` by default to generate identifiers for new data created on the client. Identifier generation is configurable, but we also for convenience will polyfill +*Ember*‍**Data** uses `UUID V4` by default to generate identifiers for new data created on the client. Identifier generation is configurable, but we also for convenience will polyfill the necessary feature if your browser support or deployment environment demands it. To activate this polyfill: diff --git a/packages/tracking/lib/transform-ext.js b/packages/private-build-infra/src/transforms/babel-plugin-transform-ext.js similarity index 100% rename from packages/tracking/lib/transform-ext.js rename to packages/private-build-infra/src/transforms/babel-plugin-transform-ext.js diff --git a/packages/request/LICENSE.md b/packages/request/LICENSE.md new file mode 100644 index 00000000000..8a71a0b5226 --- /dev/null +++ b/packages/request/LICENSE.md @@ -0,0 +1,11 @@ +The MIT License (MIT) + +Copyright (C) 2017-2022 Ember.js contributors +Portions Copyright (C) 2011-2017 Tilde, Inc. and contributors. +Portions Copyright (C) 2011 LivingSocial Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/request/README.md b/packages/request/README.md new file mode 100644 index 00000000000..508171fa861 --- /dev/null +++ b/packages/request/README.md @@ -0,0 +1,366 @@ +

+ + +

+ +

⚑️ a simple abstraction over fetch to enable easy management of request/response flows

+ +This package provides [*Ember***Data**](https://github.com/emberjs/data/)'s `RequestManager`, a framework agnostic library that can be integrated with any Javascript application to make [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) happen. + +## Installation + +Install using your javascript package manager of choice. For instance with [pnpm](https://pnpm.io/) + +```no-highlight +pnpm add @ember-data/request +``` + +## πŸš€ Basic Usage + +A `RequestManager` provides a request/response flow in which configured handlers are successively given the opportunity to handle, modify, or pass-along a request. + +The RequestManager on its own does not know how to fulfill requests. For this we must register at least one handler. A basic `Fetch` handler is provided that will take the request options provided and execute `fetch`. + +```ts +import { RequestManager } from '@ember-data/request'; +import { Fetch } from '@ember-data/request/fetch'; +import { apiUrl } from './config'; + +// ... create manager and add our Fetch handler +const manager = new RequestManager(); +manager.use([Fetch]); + +// ... execute a request +const response = await manager.request({ + url: `${apiUrl}/users` +}); +``` + + +## πŸͺœ Architecture + +A `RequestManager` receives a request and manages fulfillment via configured handlers. It may be used standalone from the rest of *Ember***Data** and is not specific to any library or framework. + +```mermaid +flowchart LR + A[fa:fa-terminal App] <--> B{{fa:fa-sitemap RequestManager}} + B <--> C[(fa:fa-database Source)] +``` + +Each handler may choose to fulfill the request using some source of data or to pass the request along to other handlers. + +```mermaid +flowchart LR + A[fa:fa-terminal App] <--> B{{fa:fa-sitemap RequestManager}} + B <--> C(handler) + C <--> E(handler) + E <--> F(handler) + C <--> D[(fa:fa-database Source)] + E <--> G[(fa:fa-database Source)] + F <--> H[(fa:fa-database Source)] +``` + +The same or a separate instance of a `RequestManager` may also be used to fulfill requests issued by [*Ember***Data**{Store}](https://github.com/emberjs/data/tree/master/packages/store) + +```mermaid +flowchart LR + A[fa:fa-terminal App] <--> D{fa:fa-code-fork Store} + B{{fa:fa-sitemap RequestManager}} <--> C[(fa:fa-database Source)] + D <--> E[(fa:fa-archive Cache)] + D <--> B + click D href "https://github.com/emberjs/data/tree/master/packages/store" "Go to @ember-data/store" _blank + click E href "https://github.com/emberjs/data/tree/master/packages/record-data" "Go to @ember-data/record-data" _blank + style D color:#58a6ff; + style E color:#58a6ff; +``` + +When the same instance is used by both this allows for simple coordination throughout the application. Requests issued by the Store will use the in-memory cache +and return hydrated responses, requests issued directly to the RequestManager +will skip the in-memory cache and return raw responses. + +```mermaid +flowchart LR + A[fa:fa-terminal App] <--> B{{fa:fa-sitemap RequestManager}} + B <--> C[(fa:fa-database Source)] + A <--> D{fa:fa-code-fork Store} + D <--> E[(fa:fa-archive Cache)] + D <--> B + click D href "https://github.com/emberjs/data/tree/master/packages/store" "Go to @ember-data/store" _blank + click E href "https://github.com/emberjs/data/tree/master/packages/record-data" "Go to @ember-data/record-data" _blank + style D color:#58a6ff; + style E color:#58a6ff; +``` + +## Usage + +
+ Making Requests + +`RequestManager` has a single asyncronous method as it's API: `request` + +```ts +class RequestManager { + async request(req: RequestInfo): Future; +} +``` + +`manager.request` accepts a `RequestInfo`, an object containing the information +necessary for the request to be handled successfully. + +`RequestInfo` extends the [options](https://developer.mozilla.org/en-US/docs/Web/API/fetch#parameters) provided to `fetch`, and can accept a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request). All properties accepted by Request options and fetch options are valid on `RequestInfo`. + +```ts +interface RequestInfo extends FetchOptions { + url: string; + /** + * data that a handler should convert into + * the query (GET) or body (POST) + */ + data?: Record; + /** + * options specifically intended for handlers + * to utilize to process the request + */ + options?: Record; +} +``` + +> **note:** providing a `signal` is unnecessary as an `AbortController` is automatically provided if none is present. + +
+
+ Using the Response
+ +`manager.request` returns a `Future`, which allows access to limited information about the request while it is still pending and fulfills with the final state when the request completes and the response has been read. + +A `Future` is cancellable via `abort`. + +Handlers may *optionally* expose a ReadableStream to the `Future` for streaming data; however, when doing so the handler should not resolve until it has fully read the response stream itself. + +```ts +interface Future extends Promise> { + abort(): void; + + async getStream(): ReadableStream | null; +} +``` + +A Future resolves or rejects with a `StructuredDocument`. + +```ts +interface StructuredDocument { + request: RequestInfo; + response: ResponseInfo | null; + data?: T; + error?: Error; +} +``` + +The `RequestInfo` specified by `document.request` is the same as originally provided to `manager.request`. If any handler fulfilled this request using different request info it is not represented here. This contract helps to ensure that `retry` and `caching` are possible since the original arguments are correctly preserved. This also allows handlers to "fork" the request or fulfill from multiple sources without the details of fulfillment muddying the original request. + +The `ResponseInfo` is a serializable fulfilled subset of a [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) if set via `setResponse`. If no response was ever set this will be `null`. + +```ts +/** + * All readonly properties available on a Response + * + */ +interface ResponseInfo { + headers?: Record; + ok?: boolean; + redirected?: boolean; + status?: HTTPStatusCode; + statusText?: string; + type?: 'basic' | 'cors'; + url?: string; +} +``` + +
+ +

Handling Requests

+
+ { async request(context, next): T; }
+ +Requests are fulfilled by handlers. A handler receives the request context +as well as a `next` function with which to pass along a request to the next +handler if it so chooses. + +A handler may be any object with a `request` method. This allows both stateful and non-stateful +handlers to be utilized. + +If a handler calls `next`, it receives a `Future` which resolves to a `StructuredDocument` +that it can then compose how it sees fit with its own response. + +```ts + +type NextFn

= (req: RequestInfo) => Future

; + +interface Handler { + async request(context: RequestContext, next: NextFn

): T; +} +``` + +`RequestContext` contains a readonly version of the RequestInfo as well as a few methods for building up the `StructuredDocument` and `Future` that will be part of the response. + +```ts +interface RequestContext { + readonly request: RequestInfo; + + setStream(stream: ReadableStream | Promise): void; + setResponse(response: Response | ResponseInfo): void; +} +``` + +A basic `fetch` handler with support for streaming content updates while +the download is still underway might look like the following, where we use +[`response.clone()`](https://developer.mozilla.org/en-US/docs/Web/API/Response/clone) to `tee` the `ReadableStream` into two streams. + +A more efficient handler might read from the response stream, building up the +response data before passing along the chunk downstream. + +```ts +const FetchHandler = { + async request(context) { + const response = await fetch(context.request); + context.setResponse(reponse); + context.setStream(response.clone().body); + + return response.json(); + } +} +``` + +Request handlers are registered by configuring the manager via `use` + +```ts +manager.use([Handler1, Handler2]) +``` + +Handlers will be invoked in the order they are registered ("fifo", first-in first-out), and may only be registered up until the first request is made. It is recommended but not required to register all handlers at one time in order to ensure explicitly visible handler ordering. + +

+ +
+ Stream Currying
+ +`RequestManager.request` and `next` differ from `fetch` in one **crucial detail** in that the outer Promise resolves only once the response stream has been processed. + +For context, it helps to understand a few of the use-cases that RequestManager +is intended to allow. + +- to manage and return streaming content (such as video files) +- to fulfill a request from multiple sources or by splitting one request into multiple requests + - for instance one API call for a user and another for the user's friends + - or e.g. fulfilling part of the request from one source (one API, in-memory, localStorage, IndexedDB + etc.) and the rest from another source (a different API, a WebWorker, etc.) +- to coalesce multiple requests +- to decorate a request with additional info + - e.g. an Auth handler that ensures the correct tokens or headers or cookies are attached. + +`await fetch()` resolves at the moment headers are received. This allows for the body of the request to be processed as a stream by application +code *while chunks are still being received by the browser*. + +When an app chooses to `await response.json()` what occurs is the browser reads the stream to completion and then returns the result. Additionally, this stream may only be read **once**. + +The `RequestManager` preserves this ability to subscribe to and utilize the stream by either the application or the handler – thereby delivering the full power and flexibility of native APIs – without restricting developers in ways that lead to complicated workarounds. + +Each handler may call `setStream` only once, but may do so *at any time* until the promise that the handler returns has resolved. The associated promise returned by calling `future.getStream` will resolve with the stream set by `setStream` if that method is called, or `null` if that method +has not been called by the time that the handler's request method has resolved. + +Handlers that do not create a stream of their own, but which call `next`, should defensively pipe the stream forward. While this is not required (see automatic currying below) it is better to do so in most cases as otherwise the stream may not become available to downstream handlers or the application until the upstream handler has fully read it. + +```ts +context.setStream(future.getStream()); +``` + +Handlers that either call `next` multiple times or otherwise have reason to create multiple fetch requests should either choose to return no stream, meaningfully combine the streams, or select a single prioritized stream. + +Of course, any handler may choose to read and handle the stream, and return either no stream or a different stream in the process. + +
+ +
+ Automatic Currying of Stream and Response
+ +In order to simplify the common case for handlers which decorate a request, if `next` is called only a single time and `setResponse` was never called by the handler, the response set by the next handler in the chain will be applied to that handler's outcome. For instance, this makes the following pattern possible `return (await next()).data;`. + +Similarly, if `next` is called only a single time and neither `setStream` nor `getStream` was called, we automatically curry the stream from the future returned by `next` onto the future returned by the handler. + +Finally, if the return value of a handler is a `Future`, we curry `data` and `errors` as well, thus enabling the simplest form `return next()`. + +In the case of the `Future` being returned, `Stream` proxying is automatic and immediate and does not wait for the `Future` to resolve. + +
+ +### Using as a Service + +Most applications will desire to have a single `RequestManager` instance, which can be achieved using module-state patterns for singletons, or for [Ember](https://emberjs.com) applications by exporting the manager as a [service](https://guides.emberjs.com/release/services/). + +*services/request.ts* +```ts +import { RequestManager } from '@ember-data/request'; +import { Fetch } from '@ember-data/request/fetch'; +import Auth from 'ember-simple-auth/ember-data-handler'; + +export default class extends RequestManager { + constructor(createArgs) { + super(createArgs); + this.use([Auth, Fetch]); + } +} +``` + +### Using with `@ember-data/store` + +To have a request service unique to a Store: + +```ts +import Store from '@ember-data/store'; +import { RequestManager } from '@ember-data/request'; +import { Fetch } from '@ember-data/request/fetch'; + +class extends Store { + requestManager = new RequestManager(); + + constructor(args) { + super(args); + this.requestManager.use([Fetch]); + } +} +``` + +### Using with `ember-data` + +If using the package [ember-data](https://github.com/emberjs/data/tree/master/packages/-ember-data), the following configuration will automatically be done in order to preserve the legacy [Adapter](https://github.com/emberjs/data/tree/master/packages/adapter) and [Serializer](https://github.com/emberjs/data/tree/master/packages/serializer) behavior. Additional handlers or a service injection like the above would need to be done by the consuming application in order to make broader use of `RequestManager`. + +```ts +import Store from '@ember-data/store'; +import { RequestManager } from '@ember-data/request'; +import { LegacyHandler } from '@ember-data/legacy-network-handler'; + +export default class extends Store { + requestManager = new RequestManager(); + + constructor(args) { + super(args); + this.requestManager.use([LegacyHandler]); + } +} +``` + +Because the application's store service (if present) will override the store supplied by `ember-data`, all that is required to define your own ordering and handlers is to supply a store service extending from `@ember-data/store` and configure as shown above. + +For usage of the store's `requestManager` via `store.request()` see the [Store](https://api.emberjs.com/ember-data/release/modules/@ember-data%2Fstore) documentation. diff --git a/packages/request/addon-main.js b/packages/request/addon-main.js new file mode 100644 index 00000000000..459ef9174ca --- /dev/null +++ b/packages/request/addon-main.js @@ -0,0 +1,19 @@ +module.exports = { + name: require('./package.json').name, + + treeForVendor() { + return; + }, + treeForPublic() { + return; + }, + treeForStyles() { + return; + }, + treeForAddonStyles() { + return; + }, + treeForApp() { + return; + }, +}; diff --git a/packages/request/babel.config.json b/packages/request/babel.config.json new file mode 100644 index 00000000000..fea1b66f091 --- /dev/null +++ b/packages/request/babel.config.json @@ -0,0 +1,8 @@ +{ + "plugins": [ + "@babel/plugin-transform-runtime", + ["@babel/plugin-transform-typescript", { "allowDeclareFields": true }], + ["@babel/plugin-proposal-decorators", { "legacy": true }], + "@babel/plugin-proposal-class-properties" + ] +} diff --git a/packages/request/ember-data-logo-dark.svg b/packages/request/ember-data-logo-dark.svg new file mode 100644 index 00000000000..737a4aa4321 --- /dev/null +++ b/packages/request/ember-data-logo-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/request/ember-data-logo-light.svg b/packages/request/ember-data-logo-light.svg new file mode 100644 index 00000000000..58ac3d4e544 --- /dev/null +++ b/packages/request/ember-data-logo-light.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/request/package.json b/packages/request/package.json new file mode 100644 index 00000000000..7507e1f4c57 --- /dev/null +++ b/packages/request/package.json @@ -0,0 +1,62 @@ +{ + "name": "@ember-data/request", + "description": "⚑️ A simple, small and fast framework-agnostic library to make `fetch` happen", + "version": "4.9.0-alpha.14", + "private": false, + "license": "MIT", + "author": "Chris Thoburn ", + "repository": { + "type": "git", + "url": "git+ssh://git@github.com:emberjs/data.git", + "directory": "packages/request" + }, + "homepage": "https://github.com/emberjs/data", + "bugs": "https://github.com/emberjs/data/issues", + "engines": { + "node": "14.* || 16.* || >= 18" + }, + "keywords": ["ember-addon"], + "volta": { + "extends": "../../package.json" + }, + "dependencies": { + "ember-cli-babel": "^7.26.11", + "@embroider/macros": "^1.9.0" + }, + "files": [ + "addon-main.js", + "addon" + ], + "scripts": { + "build": "rollup --config && babel ./addon --out-dir addon --plugins=../private-build-infra/src/transforms/babel-plugin-transform-ext.js", + "start": "rollup --config --watch", + "prepack": "pnpm build" + }, + "ember-addon": { + "main": "addon-main.js", + "type": "addon", + "version": 1 + }, + "peerDependencies": {}, + "devDependencies": { + "@embroider/addon-dev": "^2.0.0", + "rollup": "^3.2.3", + "@babel/core": "^7.19.6", + "@babel/cli": "^7.19.3", + "@babel/plugin-proposal-class-properties": "^7.18.6", + "@babel/plugin-proposal-decorators": "^7.20.0", + "@babel/plugin-transform-typescript": "^7.20.0", + "@babel/plugin-transform-runtime": "^7.19.6", + "@babel/preset-typescript": "^7.18.6", + "@babel/preset-env": "^7.19.4", + "@babel/runtime": "^7.20.0", + "@rollup/plugin-babel":"^6.0.2", + "@rollup/plugin-node-resolve": "^15.0.1", + "tslib": "^2.4.0", + "walk-sync": "^3.0.0", + "typescript": "^4.8.4" + }, + "ember": { + "edition": "octane" + } +} diff --git a/packages/request/rollup.config.mjs b/packages/request/rollup.config.mjs new file mode 100644 index 00000000000..34fe8a317de --- /dev/null +++ b/packages/request/rollup.config.mjs @@ -0,0 +1,31 @@ +import { Addon } from '@embroider/addon-dev/rollup'; +import babel from '@rollup/plugin-babel'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; + +const addon = new Addon({ + srcDir: 'src', + destDir: 'addon', +}); + +export default { + // This provides defaults that work well alongside `publicEntrypoints` below. + // You can augment this if you need to. + output: addon.output(), + + external: ['@embroider/macros'], + + plugins: [ + // These are the modules that users should be able to import from your + // addon. Anything not listed here may get optimized away. + addon.publicEntrypoints(['index.js', '-private.js']), + + nodeResolve({ extensions: ['.ts'] }), + babel({ + extensions: ['.ts'], + babelHelpers: 'runtime', // we should consider "external", + }), + + // Remove leftover build artifacts when starting a new build. + addon.clean(), + ], +}; diff --git a/packages/request/src/-private/context.ts b/packages/request/src/-private/context.ts new file mode 100644 index 00000000000..285d19c1aa5 --- /dev/null +++ b/packages/request/src/-private/context.ts @@ -0,0 +1,142 @@ +import { isDevelopingApp, macroCondition } from '@embroider/macros'; + +import { deepFreeze } from './debug'; +import { createDeferred } from './future'; +import type { Deferred, GodContext, ImmutableHeaders, ImmutableRequestInfo, RequestInfo, ResponseInfo } from './types'; + +export class ContextOwner { + hasSetStream = false; + hasSetResponse = false; + hasSubscribers = false; + stream: Deferred = createDeferred(); + response: ResponseInfo | null = null; + request: ImmutableRequestInfo; + enhancedRequest: ImmutableRequestInfo; + nextCalled: number = 0; + god: GodContext; + controller: AbortController; + + constructor(request: RequestInfo, god: GodContext) { + this.controller = request.controller || god.controller; + if (request.controller) { + if (request.controller !== god.controller) { + god.controller.signal.addEventListener('abort', () => { + this.controller.abort(); + }); + } + delete request.controller; + } + let enhancedRequest: ImmutableRequestInfo = Object.assign( + { signal: god.controller.signal }, + request + ) as ImmutableRequestInfo; + if (macroCondition(isDevelopingApp())) { + request = deepFreeze(request) as ImmutableRequestInfo; + enhancedRequest = deepFreeze(enhancedRequest); + } else { + if (request.headers) { + (request.headers as ImmutableHeaders).clone = () => { + return new Headers([...request.headers!.entries()]); + }; + (request.headers as ImmutableHeaders).toJSON = () => { + return [...request.headers!.entries()]; + }; + } + } + this.enhancedRequest = enhancedRequest; + this.request = request as ImmutableRequestInfo; + this.god = god; + this.stream.promise = this.stream.promise.then((stream: ReadableStream | null) => { + if (this.god.stream === stream && this.hasSubscribers) { + this.god.stream = null; + } + return stream; + }); + } + + getResponse(): ResponseInfo | null { + if (this.hasSetResponse) { + return this.response; + } + if (this.nextCalled === 1) { + return this.god.response; + } + return null; + } + getStream(): Promise { + this.hasSubscribers = true; + return this.stream.promise; + } + abort() { + this.controller.abort(); + } + + setStream(stream: ReadableStream | Promise | null) { + if (!this.hasSetStream) { + this.hasSetStream = true; + + if (!(stream instanceof Promise)) { + this.god.stream = stream; + } + // @ts-expect-error + this.stream.resolve(stream); + } + } + + resolveStream() { + this.setStream(this.nextCalled === 1 ? this.god.stream : null); + } + + setResponse(response: ResponseInfo | Response | null) { + if (this.hasSetResponse) { + if (macroCondition(isDevelopingApp())) { + throw new Error(`Cannot setResponse when a response has already been set`); + } + return; + } + this.hasSetResponse = true; + if (response instanceof Response) { + const { headers, ok, redirected, status, statusText, type, url } = response; + (headers as ImmutableHeaders).clone = () => { + return new Headers([...headers.entries()]); + }; + (headers as ImmutableHeaders).toJSON = () => { + return [...headers.entries()]; + }; + let responseData: ResponseInfo = { + headers: headers as ImmutableHeaders, + ok, + redirected, + status, + statusText, + type, + url, + }; + if (macroCondition(isDevelopingApp())) { + responseData = deepFreeze(responseData); + } + this.response = responseData; + this.god.response = responseData; + } else { + this.response = response; + this.god.response = response; + } + } +} + +export class Context { + #owner: ContextOwner; + request: ImmutableRequestInfo; + + constructor(owner: ContextOwner) { + this.#owner = owner; + this.request = owner.enhancedRequest; + } + setStream(stream: ReadableStream | Promise) { + this.#owner.setStream(stream); + } + setResponse(response: ResponseInfo | Response | null) { + this.#owner.setResponse(response); + } +} +export type HandlerRequestContext = Context; diff --git a/packages/request/src/-private/debug.ts b/packages/request/src/-private/debug.ts new file mode 100644 index 00000000000..775dbcfce1e --- /dev/null +++ b/packages/request/src/-private/debug.ts @@ -0,0 +1,310 @@ +import { isDevelopingApp, macroCondition } from '@embroider/macros'; + +import { Context } from './context'; +import type { ImmutableHeaders, RequestInfo } from './types'; + +const ValidKeys = new Map([ + ['data', 'json'], + ['options', 'object'], + ['url', 'string'], + ['cache', ['default', 'force-cache', 'no-cache', 'no-store', 'only-if-cached', 'reload']], + ['credentials', ['include', 'omit', 'same-origin']], + [ + 'destination', + [ + '', + 'object', + 'audio', + 'audioworklet', + 'document', + 'embed', + 'font', + 'frame', + 'iframe', + 'image', + 'manifest', + 'paintworklet', + 'report', + 'script', + 'sharedworker', + 'style', + 'track', + 'video', + 'worker', + 'xslt', + ], + ], + ['headers', 'headers'], + ['integrity', 'string'], + ['keepalive', 'boolean'], + ['method', ['GET', 'PUT', 'PATCH', 'DELETE', 'POST', 'OPTIONS']], + ['mode', ['same-origin', 'cors', 'navigate', 'no-cors']], + ['redirect', ['error', 'follow', 'manual']], + ['referrer', 'string'], + ['signal', 'AbortSignal'], + ['controller', 'AbortController'], + [ + 'referrerPolicy', + [ + '', + 'same-origin', + 'no-referrer', + 'no-referrer-when-downgrade', + 'origin', + 'origin-when-cross-origin', + 'strict-origin', + 'strict-origin-when-cross-origin', + 'unsafe-url', + ], + ], +]); + +const IS_FROZEN = Symbol('FROZEN'); + +function freezeHeaders(headers: Headers | ImmutableHeaders): ImmutableHeaders { + headers.delete = + headers.set = + headers.append = + () => { + throw new Error(`Cannot Mutate Immutatable Headers, use headers.clone to get a copy`); + }; + (headers as ImmutableHeaders).clone = () => { + return new Headers([...headers.entries()]); + }; + return headers as ImmutableHeaders; +} + +export function deepFreeze(value: T): T { + if (value && value[IS_FROZEN]) { + return value; + } + const _type = typeof value; + switch (_type) { + case 'boolean': + case 'string': + case 'number': + case 'symbol': + case 'undefined': + case 'bigint': + return value; + case 'function': + throw new Error(`Cannot deep-freeze a function`); + case 'object': { + const _niceType = niceTypeOf(value); + switch (_niceType) { + case 'array': { + const arr = (value as unknown[]).map(deepFreeze); + arr[IS_FROZEN] = true; + return Object.freeze(arr) as T; + } + case 'null': + return value; + case 'object': + Object.keys(value as {}).forEach((key) => { + (value as {})[key] = deepFreeze((value as {})[key]) as {}; + }); + value[IS_FROZEN] = true; + return Object.freeze(value); + case 'headers': + return freezeHeaders(value as Headers) as T; + case 'AbortSignal': + return value; + case 'date': + case 'map': + case 'set': + case 'error': + case 'stream': + default: + throw new Error(`Cannot deep-freeze ${_niceType}`); + } + } + } +} + +function isMaybeContext(request: unknown) { + if (request && typeof request === 'object') { + const keys = Object.keys(request); + if (keys.length === 1 && keys[0] === 'request') { + return true; + } + } + return false; +} + +function niceTypeOf(v: unknown) { + if (v === null) { + return 'null'; + } + if (typeof v === 'string') { + return v ? 'non-empty-string' : 'empty-string'; + } + if (!v) { + return typeof v; + } + if (Array.isArray(v)) { + return 'array'; + } + if (v instanceof Date) { + return 'date'; + } + if (v instanceof Map) { + return 'map'; + } + if (v instanceof Set) { + return 'set'; + } + if (v instanceof Error) { + return 'error'; + } + if (v instanceof ReadableStream || v instanceof WritableStream || v instanceof TransformStream) { + return 'stream'; + } + if (v instanceof Headers) { + return 'headers'; + } + if (typeof v === 'object' && v.constructor && v.constructor.name !== 'Object') { + return v.constructor.name; + } + return typeof v; +} + +function validateKey(key: string, value: unknown, errors: string[]) { + const schema = ValidKeys.get(key); + if (!schema && !IgnoredKeys.has(key)) { + errors.push(`InvalidKey: '${key}'`); + return; + } + if (schema) { + if (Array.isArray(schema)) { + if (!schema.includes(value as string)) { + errors.push( + `InvalidValue: key ${key} should be a one of '${schema.join("', '")}', received ${ + typeof value === 'string' ? value : '' + }` + ); + } + return; + } else if (schema === 'json') { + try { + JSON.stringify(value); + } catch (e) { + errors.push( + `InvalidValue: key ${key} should be a JSON serializable value, but failed to serialize with Error - ${ + (e as Error).message + }` + ); + } + return; + } else if (schema === 'headers') { + if (!(value instanceof Headers)) { + errors.push(`InvalidValue: key ${key} should be an instance of Headers, received ${niceTypeOf(value)}`); + } + return; + } else if (schema === 'record') { + const _type = typeof value; + // record must extend plain object or Object.create(null) + if (!value || _type !== 'object' || (value.constructor && value.constructor !== Object)) { + errors.push( + `InvalidValue: key ${key} should be a dictionary of string keys to string values, received ${niceTypeOf( + value + )}` + ); + return; + } + const keys = Object.keys(value); + keys.forEach((k) => { + let v: unknown = value[k]; + if (typeof k !== 'string') { + errors.push(`\tThe key ${String(k)} on ${key} should be a string key`); + } else if (typeof v !== 'string') { + errors.push(`\tThe value of ${key}.${k} should be a string not ${niceTypeOf(v)}`); + } + }); + return; + } else if (schema === 'string') { + if (typeof value !== 'string' || value.length === 0) { + errors.push( + `InvalidValue: key ${key} should be a non-empty string, received ${ + typeof value === 'string' ? "''" : typeof value + }` + ); + } + return; + } else if (schema === 'object') { + if (!value || Array.isArray(value) || typeof value !== 'object') { + errors.push(`InvalidValue: key ${key} should be an object`); + } + return; + } else if (schema === 'boolean') { + if (typeof value !== 'boolean') { + errors.push(`InvalidValue: key ${key} should be a boolean, received ${typeof value}`); + } + return; + } + } +} + +const IgnoredKeys = new Set([]); + +export function assertValidRequest( + request: RequestInfo | Context, + isTopLevel: boolean +): asserts request is RequestInfo { + if (macroCondition(isDevelopingApp())) { + // handle basic shape + if (!request) { + throw new Error( + `Expected ${ + isTopLevel ? 'RequestManager.request' : 'next' + }() to be called with a request, but none was provided.` + ); + } + if (Array.isArray(request) || typeof request !== 'object') { + throw new Error( + `The \`request\` passed to \`${ + isTopLevel ? 'RequestManager.request' : 'next' + }()\` should be an object, received \`${niceTypeOf(request)}\`` + ); + } + if (Object.keys(request).length === 0) { + throw new Error( + `The \`request\` passed to \`${ + isTopLevel ? 'RequestManager.request' : 'next' + }()\` was empty (\`{}\`). Requests need at least one valid key.` + ); + } + + // handle accidentally passing context entirely + if (request instanceof Context) { + throw new Error( + `Expected a request passed to \`${ + isTopLevel ? 'RequestManager.request' : 'next' + }()\` but received the previous handler's context instead` + ); + } + // handle Object.assign({}, context); + if (isMaybeContext(request)) { + throw new Error( + `Expected a request passed to \`${ + isTopLevel ? 'RequestManager.request' : 'next' + }()\` but received an object with a request key instead.` + ); + } + + // handle schema + const keys = Object.keys(request); + const validationErrors = []; + keys.forEach((key) => { + validateKey(key, request[key], validationErrors); + }); + if (validationErrors.length) { + const error: Error & { errors: string[] } = new Error( + `Invalid Request passed to \`${ + isTopLevel ? 'RequestManager.request' : 'next' + }()\`.\n\nThe following issues were found:\n\n\t${validationErrors.join('\n\t')}` + ) as Error & { errors: string[] }; + error.errors = validationErrors; + throw error; + } + } +} diff --git a/packages/request/src/-private/future.ts b/packages/request/src/-private/future.ts new file mode 100644 index 00000000000..cbfe798256b --- /dev/null +++ b/packages/request/src/-private/future.ts @@ -0,0 +1,35 @@ +import type { ContextOwner } from './context'; +import type { Deferred, DeferredFuture, Future } from './types'; + +const IS_FUTURE = Symbol('IS_FUTURE'); + +export function isFuture(maybe: T | Future | Promise): maybe is Future { + return maybe[IS_FUTURE] === true; +} + +export function createDeferred(): Deferred { + let resolve!: (v: T) => void; + let reject!: (v: unknown) => void; + let promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { resolve, reject, promise }; +} + +export function createFuture(owner: ContextOwner): DeferredFuture { + const deferred = createDeferred() as unknown as DeferredFuture; + let { promise } = deferred; + promise = promise.finally(() => { + owner.resolveStream(); + }) as Future; + promise[IS_FUTURE] = true; + promise.getStream = () => { + return owner.getStream(); + }; + promise.abort = () => { + owner.abort(); + }; + deferred.promise = promise; + return deferred; +} diff --git a/packages/request/src/-private/manager.ts b/packages/request/src/-private/manager.ts new file mode 100644 index 00000000000..e1d47a76ea4 --- /dev/null +++ b/packages/request/src/-private/manager.ts @@ -0,0 +1,479 @@ +/* eslint-disable no-irregular-whitespace */ +/** + * +

+ +

+ +

⚑️ a simple abstraction over fetch to enable easy management of request/response flows

+ +This package provides [*Ember*‍**Data**](https://github.com/emberjs/data/)'s `RequestManager`, a framework agnostic library that can be integrated with any Javascript application to make [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) happen. + +## Installation + +Install using your javascript package manager of choice. For instance with [pnpm](https://pnpm.io/) + +```no-highlight +pnpm add @ember-data/request +``` + +## πŸš€ Basic Usage + +A `RequestManager` provides a request/response flow in which configured handlers are successively given the opportunity to handle, modify, or pass-along a request. + +The RequestManager on its own does not know how to fulfill requests. For this we must register at least one handler. A basic `Fetch` handler is provided that will take the request options provided and execute `fetch`. + +```ts +import { RequestManager } from '@ember-data/request'; +import { Fetch } from '@ember-data/request/fetch'; +import { apiUrl } from './config'; + +// ... create manager and add our Fetch handler +const manager = new RequestManager(); +manager.use([Fetch]); + +// ... execute a request +const response = await manager.request({ + url: `${apiUrl}/users` +}); +``` + + +## πŸͺœ Architecture + +A `RequestManager` receives a request and manages fulfillment via configured handlers. It may be used standalone from the rest of *Ember*‍**Data** and is not specific to any library or framework. + +Each handler may choose to fulfill the request using some source of data or to pass the request along to other handlers. + +The same or a separate instance of a `RequestManager` may also be used to fulfill requests issued by [*Ember*‍**Data**{Store}](https://github.com/emberjs/data/tree/master/packages/store) + +When the same instance is used by both this allows for simple coordination throughout the application. Requests issued by the Store will use the in-memory cache +and return hydrated responses, requests issued directly to the RequestManager +will skip the in-memory cache and return raw responses. + +## Usage + +
+ Making Requests + +`RequestManager` has a single asyncronous method as it's API: `request` + +```ts +class RequestManager { + async request(req: RequestInfo): Future; +} +``` + +`manager.request` accepts a `RequestInfo`, an object containing the information +necessary for the request to be handled successfully. + +`RequestInfo` extends the [options](https://developer.mozilla.org/en-US/docs/Web/API/fetch#parameters) provided to `fetch`, and can accept a [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request/Request). All properties accepted by Request options and fetch options are valid on `RequestInfo`. + +```ts +interface RequestInfo extends FetchOptions { + url: string; + // data that a handler should convert into + // the query (GET) or body (POST) + data?: Record; + + // options specifically intended for handlers + // to utilize to process the request + options?: Record; +} +``` + +> **note:** providing a `signal` is unnecessary as an `AbortController` is automatically provided if none is present. + +
+
+ Using the Response
+ +`manager.request` returns a `Future`, which allows access to limited information about the request while it is still pending and fulfills with the final state when the request completes and the response has been read. + +A `Future` is cancellable via `abort`. + +Handlers may *optionally* expose a ReadableStream to the `Future` for streaming data; however, when doing so the handler should not resolve until it has fully read the response stream itself. + +```ts +interface Future extends Promise> { + abort(): void; + + async getStream(): ReadableStream | null; +} +``` + +A Future resolves or rejects with a `StructuredDocument`. + +```ts +interface StructuredDocument { + request: RequestInfo; + response: ResponseInfo | null; + data?: T; + error?: Error; +} +``` + +The `RequestInfo` specified by `document.request` is the same as originally provided to `manager.request`. If any handler fulfilled this request using different request info it is not represented here. This contract helps to ensure that `retry` and `caching` are possible since the original arguments are correctly preserved. This also allows handlers to "fork" the request or fulfill from multiple sources without the details of fulfillment muddying the original request. + +The `ResponseInfo` is a serializable fulfilled subset of a [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response) if set via `setResponse`. If no response was ever set this will be `null`. + +```ts +interface ResponseInfo { + headers?: Record; + ok?: boolean; + redirected?: boolean; + status?: HTTPStatusCode; + statusText?: string; + type?: 'basic' | 'cors'; + url?: string; +} +``` + +
+ +

Handling Requests

+
+ { request(context, next): Promise | Future; }
+ +Requests are fulfilled by handlers. A handler receives the request context +as well as a `next` function with which to pass along a request to the next +handler if it so chooses. + +A handler may be any object with a `request` method. This allows both stateful and non-stateful +handlers to be utilized. + +If a handler calls `next`, it receives a `Future` which resolves to a `StructuredDocument` +that it can then compose how it sees fit with its own response. + +```ts + +type NextFn

= (req: RequestInfo) => Future

; + +interface Handler { + async request(context: RequestContext, next: NextFn

): T; +} +``` + +`RequestContext` contains a readonly version of the RequestInfo as well as a few methods for building up the `StructuredDocument` and `Future` that will be part of the response. + +```ts +interface RequestContext { + readonly request: RequestInfo; + + setStream(stream: ReadableStream | Promise): void; + setResponse(response: Response | ResponseInfo): void; +} +``` + +A basic `fetch` handler with support for streaming content updates while +the download is still underway might look like the following, where we use +[`response.clone()`](https://developer.mozilla.org/en-US/docs/Web/API/Response/clone) to `tee` the `ReadableStream` into two streams. + +A more efficient handler might read from the response stream, building up the +response data before passing along the chunk downstream. + +```ts +const FetchHandler = { + async request(context) { + const response = await fetch(context.request); + context.setResponse(reponse); + context.setStream(response.clone().body); + + return response.json(); + } +} +``` + +Request handlers are registered by configuring the manager via `use` + +```ts +manager.use([Handler1, Handler2]) +``` + +Handlers will be invoked in the order they are registered ("fifo", first-in first-out), and may only be registered up until the first request is made. It is recommended but not required to register all handlers at one time in order to ensure explicitly visible handler ordering. + +

+ +
+ Stream Currying
+ +`RequestManager.request` and `next` differ from `fetch` in one **crucial detail** in that the outer Promise resolves only once the response stream has been processed. + +For context, it helps to understand a few of the use-cases that RequestManager +is intended to allow. + +- to manage and return streaming content (such as video files) +- to fulfill a request from multiple sources or by splitting one request into multiple requests + - for instance one API call for a user and another for the user's friends + - or e.g. fulfilling part of the request from one source (one API, in-memory, localStorage, IndexedDB + etc.) and the rest from another source (a different API, a WebWorker, etc.) +- to coalesce multiple requests +- to decorate a request with additional info + - e.g. an Auth handler that ensures the correct tokens or headers or cookies are attached. + +`await fetch()` resolves at the moment headers are received. This allows for the body of the request to be processed as a stream by application +code *while chunks are still being received by the browser*. + +When an app chooses to `await response.json()` what occurs is the browser reads the stream to completion and then returns the result. Additionally, this stream may only be read **once**. + +The `RequestManager` preserves this ability to subscribe to and utilize the stream by either the application or the handler – thereby delivering the full power and flexibility of native APIs – without restricting developers in ways that lead to complicated workarounds. + +Each handler may call `setStream` only once, but may do so *at any time* until the promise that the handler returns has resolved. The associated promise returned by calling `future.getStream` will resolve with the stream set by `setStream` if that method is called, or `null` if that method +has not been called by the time that the handler's request method has resolved. + +Handlers that do not create a stream of their own, but which call `next`, should defensively pipe the stream forward. While this is not required (see automatic currying below) it is better to do so in most cases as otherwise the stream may not become available to downstream handlers or the application until the upstream handler has fully read it. + +```ts +context.setStream(future.getStream()); +``` + +Handlers that either call `next` multiple times or otherwise have reason to create multiple fetch requests should either choose to return no stream, meaningfully combine the streams, or select a single prioritized stream. + +Of course, any handler may choose to read and handle the stream, and return either no stream or a different stream in the process. + +
+ +
+ Automatic Currying of Stream and Response
+ +In order to simplify the common case for handlers which decorate a request, if `next` is called only a single time and `setResponse` was never called by the handler, the response set by the next handler in the chain will be applied to that handler's outcome. For instance, this makes the following pattern possible `return (await next()).data;`. + +Similarly, if `next` is called only a single time and neither `setStream` nor `getStream` was called, we automatically curry the stream from the future returned by `next` onto the future returned by the handler. + +Finally, if the return value of a handler is a `Future`, we curry `data` and `errors` as well, thus enabling the simplest form `return next()`. + +In the case of the `Future` being returned, `Stream` proxying is automatic and immediate and does not wait for the `Future` to resolve. + +
+ +### Using as a Service + +Most applications will desire to have a single `RequestManager` instance, which can be achieved using module-state patterns for singletons, or for [Ember](https://emberjs.com) applications by exporting the manager as a [service](https://guides.emberjs.com/release/services/). + +*services/request.ts* +```ts +import { RequestManager } from '@ember-data/request'; +import { Fetch } from '@ember-data/request/fetch'; +import Auth from 'ember-simple-auth/ember-data-handler'; + +export default class extends RequestManager { + constructor(createArgs) { + super(createArgs); + this.use([Auth, Fetch]); + } +} +``` + +### Using with `@ember-data/store` + +To have a request service unique to a Store: + +```ts +import Store from '@ember-data/store'; +import { RequestManager } from '@ember-data/request'; +import { Fetch } from '@ember-data/request/fetch'; + +class extends Store { + requestManager = new RequestManager(); + + constructor(args) { + super(args); + this.requestManager.use([Fetch]); + } +} +``` + +### Using with `ember-data` + +If using the package [ember-data](https://github.com/emberjs/data/tree/master/packages/-ember-data), the following configuration will automatically be done in order to preserve the legacy [Adapter](https://github.com/emberjs/data/tree/master/packages/adapter) and [Serializer](https://github.com/emberjs/data/tree/master/packages/serializer) behavior. Additional handlers or a service injection like the above would need to be done by the consuming application in order to make broader use of `RequestManager`. + +```ts +import Store from '@ember-data/store'; +import { RequestManager } from '@ember-data/request'; +import { LegacyHandler } from '@ember-data/legacy-network-handler'; + +export default class extends Store { + requestManager = new RequestManager(); + + constructor(args) { + super(args); + this.requestManager.use([LegacyHandler]); + } +} +``` + +Because the application's store service (if present) will override the store supplied by `ember-data`, all that is required to define your own ordering and handlers is to supply a store service extending from `@ember-data/store` and configure as shown above. + +For usage of the store's `requestManager` via `store.request()` see the [Store](https://api.emberjs.com/ember-data/release/modules/@ember-data%2Fstore) documentation. + + * + * @module @ember-data/request + * @main @ember-data/request + */ +import { isDevelopingApp, isTesting, macroCondition } from '@embroider/macros'; + +import { assertValidRequest } from './debug'; +import { Future, GenericCreateArgs, Handler, RequestInfo } from './types'; +import { executeNextHandler } from './utils'; + +/** + * ```js + * import { RequestManager } from '@ember-data/request'; + * ``` + * + * A RequestManager provides a request/response flow in which configured + * handlers are successively given the opportunity to handle, modify, or + * pass-along a request. + * + * ```ts + * interface RequestManager { + * request(req: RequestInfo): Future; + * } + * ``` + * + * For example: + * + * ```ts + * import { RequestManager } from '@ember-data/request'; + * import { Fetch } from '@ember/data/request/fetch'; + * import Auth from 'ember-simple-auth/ember-data-handler'; + * import Config from './config'; + * + * const { apiUrl } = Config; + * + * // ... create manager + * const manager = new RequestManager(); + * manager.use([Auth, Fetch]); + * + * // ... execute a request + * const response = await manager.request({ + * url: `${apiUrl}/users` + * }); + * ``` + * + * ### Futures + * + * The return value of `manager.request` is a `Future`, which allows + * access to limited information about the request while it is still + * pending and fulfills with the final state when the request completes. + * + * A `Future` is cancellable via `abort`. + * + * Handlers may optionally expose a `ReadableStream` to the `Future` for + * streaming data; however, when doing so the future should not resolve + * until the response stream is fully read. + * + * ```ts + * interface Future extends Promise> { + * abort(): void; + * + * async getStream(): ReadableStream | null; + * } + * ``` + * + * ### StructuredDocuments + * + * A Future resolves with a `StructuredDataDocument` or rejects with a `StructuredErrorDocument`. + * + * ```ts + * interface StructuredDataDocument { + * request: ImmutableRequestInfo; + * response: ImmutableResponseInfo; + * data: T; + * } + * interface StructuredErrorDocument extends Error { + * request: ImmutableRequestInfo; + * response: ImmutableResponseInfo; + * error: string | object; + * } + * type StructuredDocument = StructuredDataDocument | StructuredErrorDocument; + * ``` + * + * @class RequestManager + * @public + */ +export class RequestManager { + #handlers: Handler[] = []; + + constructor(options?: GenericCreateArgs) { + Object.assign(this, options); + } + + /** + * Register handler(s) to use when a request is issued. + * + * Handlers will be invoked in the order they are registered. + * Each Handler is given the opportunity to handle the request, + * curry the request, or pass along a modified request. + * + * @method use + * @public + * @param {Hanlder[]} newHandlers + * @returns {void} + */ + use(newHandlers: Handler[]) { + const handlers = this.#handlers; + if (macroCondition(isDevelopingApp())) { + if (Object.isFrozen(handlers)) { + throw new Error(`Cannot add a Handler to a RequestManager after a request has been made`); + } + if (!Array.isArray(newHandlers)) { + throw new Error( + `\`RequestManager.use()\` expects an array of handlers, but was called with \`${typeof newHandlers}\`` + ); + } + newHandlers.forEach((handler, index) => { + if (!handler || typeof handler !== 'object' || typeof handler.request !== 'function') { + throw new Error( + `\`RequestManager.use()\` expected to receive an array of handler objects with request methods, by the handler at index ${index} does not conform.` + ); + } + }); + } + handlers.push(...newHandlers); + } + + /** + * Issue a Request. + * + * Returns a Future that fulfills with a StructuredDocument + * + * @method request + * @public + * @param {RequestInfo} request + * @returns {Future} + */ + request(request: RequestInfo): Future { + const handlers = this.#handlers; + if (macroCondition(isDevelopingApp())) { + if (!Object.isFrozen(handlers)) { + Object.freeze(handlers); + } + assertValidRequest(request, true); + } + const controller = request.controller || new AbortController(); + if (request.controller) { + delete request.controller; + } + let promise = executeNextHandler(handlers, request, 0, { + controller, + response: null, + stream: null, + }); + if (macroCondition(isTesting())) { + // const { waitForPromise } = importSync('ember-test-waiters'); + // promise = waitForPromise(promise); + } + return promise; + } + + static create(options?: GenericCreateArgs) { + return new this(options); + } +} diff --git a/packages/request/src/-private/types.ts b/packages/request/src/-private/types.ts new file mode 100644 index 00000000000..346268c4bef --- /dev/null +++ b/packages/request/src/-private/types.ts @@ -0,0 +1,164 @@ +/** + * @module @ember-data/request + */ +interface Request { + controller?: AbortController; + /* Returns the cache mode associated with request, which is a string indicating how the request will interact with the browser's cache when fetching. */ + cache?: RequestCache; + /* Returns the credentials mode associated with request, which is a string indicating whether credentials will be sent with the request always, never, or only when sent to a same-origin URL. */ + credentials?: RequestCredentials; + /* Returns the kind of resource requested by request, e.g., "document" or "script". */ + destination?: RequestDestination; + /* Returns a Headers object consisting of the headers associated with request. Note that headers added in the network layer by the user agent will not be accounted for in this object, e.g., the "Host" header. */ + headers?: Headers; + /* Returns request's subresource integrity metadata, which is a cryptographic hash of the resource being fetched. Its value consists of multiple hashes separated by whitespace. [SRI] */ + integrity?: string; + /* Returns a boolean indicating whether or not request can outlive the global in which it was created. */ + keepalive?: boolean; + /* Returns request's HTTP method, which is "GET" by default. */ + method?: string; + /* Returns the mode associated with request, which is a string indicating whether the request will use CORS, or will be restricted to same-origin URLs. */ + mode?: RequestMode; + /* Returns the redirect mode associated with request, which is a string indicating how redirects for the request will be handled during fetching. A request will follow redirects by default. */ + redirect?: RequestRedirect; + /* Returns the referrer of request. Its value can be a same-origin URL if explicitly set in init, the empty string to indicate no referrer, and "about:client" when defaulting to the global's default. This is used during fetching to determine the value of the `Referer` header of the request being made. */ + referrer?: string; + /* Returns the referrer policy associated with request. This is used during fetching to compute the value of the request's referrer. */ + referrerPolicy?: ReferrerPolicy; + /* Returns the signal associated with request, which is an AbortSignal object indicating whether or not request has been aborted, and its abort event handler. */ + signal?: AbortSignal; + /* Returns the URL of request as a string. */ + url?: string; +} +export type ImmutableHeaders = Headers & { clone(): Headers; toJSON(): [string, string][] }; +export interface GodContext { + controller: AbortController; + response: ResponseInfo | null; + stream: ReadableStream | Promise | null; +} + +export interface StructuredDataDocument { + request: RequestInfo; + response: Response | ResponseInfo | null; + data: T; +} +export interface StructuredErrorDocument extends Error { + request: RequestInfo; + response: Response | ResponseInfo | null; + error: string | object; +} + +export type Deferred = { + resolve(v: T): void; + reject(v: unknown): void; + promise: Promise; +}; + +/** + * @class Future + * @internal + */ +export type Future = Promise> & { + /** + * Cancel this request by firing the AbortController's signal. + * + * @method abort + * @internal + * @returns {void} + */ + abort(): void; + /** + * Get the response stream, if any, once made available. + * + * @method getStream + * @internal + * @returns {Promise} + */ + getStream(): Promise; +}; + +export type DeferredFuture = { + resolve(v: StructuredDataDocument): void; + reject(v: unknown): void; + promise: Future; +}; + +export interface RequestInfo extends Request { + /* + * data that a handler should convert into + * the query (GET) or body (POST) + */ + data?: Record; + /* + * options specifically intended for handlers + * to utilize to process the request + */ + options?: Record; +} + +export interface ImmutableRequestInfo { + /* Returns the cache mode associated with request, which is a string indicating how the request will interact with the browser's cache when fetching. */ + readonly cache?: RequestCache; + /* Returns the credentials mode associated with request, which is a string indicating whether credentials will be sent with the request always, never, or only when sent to a same-origin URL. */ + readonly credentials?: RequestCredentials; + /* Returns the kind of resource requested by request, e.g., "document" or "script". */ + readonly destination?: RequestDestination; + /* Returns a Headers object consisting of the headers associated with request. Note that headers added in the network layer by the user agent will not be accounted for in this object, e.g., the "Host" header. */ + readonly headers?: Headers & { clone(): Headers }; + /* Returns request's subresource integrity metadata, which is a cryptographic hash of the resource being fetched. Its value consists of multiple hashes separated by whitespace. [SRI] */ + readonly integrity?: string; + /* Returns a boolean indicating whether or not request can outlive the global in which it was created. */ + readonly keepalive?: boolean; + /* Returns request's HTTP method, which is "GET" by default. */ + readonly method?: string; + /* Returns the mode associated with request, which is a string indicating whether the request will use CORS, or will be restricted to same-origin URLs. */ + readonly mode?: RequestMode; + /* Returns the redirect mode associated with request, which is a string indicating how redirects for the request will be handled during fetching. A request will follow redirects by default. */ + readonly redirect?: RequestRedirect; + /* Returns the referrer of request. Its value can be a same-origin URL if explicitly set in init, the empty string to indicate no referrer, and "about:client" when defaulting to the global's default. This is used during fetching to determine the value of the `Referer` header of the request being made. */ + readonly referrer?: string; + /* Returns the referrer policy associated with request. This is used during fetching to compute the value of the request's referrer. */ + readonly referrerPolicy?: ReferrerPolicy; + /* Returns the signal associated with request, which is an AbortSignal object indicating whether or not request has been aborted, and its abort event handler. */ + readonly signal?: AbortSignal; + /* Returns the URL of request as a string. */ + readonly url?: string; + /* + * data that a handler should convert into + * the query (GET) or body (POST) + */ + readonly data?: Record; + /* + * options specifically intended for handlers + * to utilize to process the request + */ + readonly options?: Record; +} + +export interface ResponseInfo { + readonly headers: ImmutableHeaders; // to do, maybe not this? + readonly ok: boolean; + readonly redirected: boolean; + readonly status: number; + readonly statusText: string; + readonly type: string; + readonly url: string; +} + +export interface RequestContext { + request: ImmutableRequestInfo; + + setStream(stream: ReadableStream): void; + setResponse(response: Response | ResponseInfo): void; +} + +export type NextFn

= (req: RequestInfo) => Future

; +export interface Handler { + request(context: RequestContext, next: NextFn): Promise | Future; +} + +export interface RequestResponse { + result: T; +} + +export type GenericCreateArgs = Record; diff --git a/packages/request/src/-private/utils.ts b/packages/request/src/-private/utils.ts new file mode 100644 index 00000000000..d7d803c2732 --- /dev/null +++ b/packages/request/src/-private/utils.ts @@ -0,0 +1,126 @@ +import { isDevelopingApp, macroCondition } from '@embroider/macros'; + +import { Context, ContextOwner } from './context'; +import { assertValidRequest } from './debug'; +import { createFuture, isFuture } from './future'; +import type { + DeferredFuture, + Future, + GodContext, + Handler, + RequestInfo, + StructuredDataDocument, + StructuredErrorDocument, +} from './types'; + +const STRUCTURED = Symbol('DOC'); + +export function curryFuture(owner: ContextOwner, inbound: Future, outbound: DeferredFuture): Future { + owner.setStream(inbound.getStream()); + + inbound.then( + (doc: StructuredDataDocument) => { + const document = { + [STRUCTURED]: true, + request: owner.request, + response: doc.response, + data: doc.data, + }; + outbound.resolve(document); + }, + (doc: StructuredErrorDocument) => { + const document = new Error(doc.message) as unknown as StructuredErrorDocument; + document[STRUCTURED] = true; + document.stack = doc.stack; + document.request = owner.request; + document.response = owner.response; + document.error = doc.error || doc.message; + + outbound.reject(document); + } + ); + return outbound.promise; +} + +function isDoc(doc: T | StructuredDataDocument): doc is StructuredDataDocument { + return doc[STRUCTURED] === true; +} + +export function handleOutcome(owner: ContextOwner, inbound: Promise, outbound: DeferredFuture): Future { + inbound.then( + (data: T) => { + if (owner.controller.signal.aborted) { + // the next function did not respect the signal, we handle it here + outbound.reject(new DOMException((owner.controller.signal.reason as string) || 'AbortError')); + return; + } + if (isDoc(data)) { + owner.setStream(owner.god.stream); + data = data.data; + } + const document = { + [STRUCTURED]: true, + request: owner.request, + response: owner.getResponse(), + data, + }; + outbound.resolve(document); + }, + (error: Error & StructuredErrorDocument) => { + if (isDoc(error)) { + owner.setStream(owner.god.stream); + } + error[STRUCTURED] = true; + error.request = owner.request; + error.response = owner.getResponse(); + error.error = error.error || error.message; + outbound.reject(error); + } + ); + return outbound.promise; +} + +export function executeNextHandler( + wares: Readonly, + request: RequestInfo, + i: number, + god: GodContext +): Future { + if (macroCondition(isDevelopingApp())) { + if (i === wares.length) { + throw new Error(`No handler was able to handle this request.`); + } + assertValidRequest(request, false); + } + const owner = new ContextOwner(request, god); + + function next(r: RequestInfo): Future { + owner.nextCalled++; + return executeNextHandler(wares, r, i + 1, god); + } + + const context = new Context(owner); + let outcome: Promise | Future; + try { + outcome = wares[i].request(context, next); + if (macroCondition(isDevelopingApp())) { + if (!(outcome instanceof Promise)) { + // eslint-disable-next-line no-console + console.log({ request, handler: wares[i], outcome }); + if (outcome === undefined) { + throw new Error(`Expected handler.request to return a promise, instead received undefined.`); + } + throw new Error(`Expected handler.request to return a promise, instead received a synchronous value.`); + } + } + } catch (e) { + outcome = Promise.reject(e); + } + const future = createFuture(owner); + + if (isFuture(outcome)) { + return curryFuture(owner, outcome, future); + } + + return handleOutcome(owner, outcome, future); +} diff --git a/packages/request/src/.gitkeep b/packages/request/src/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/request/src/fetch.ts b/packages/request/src/fetch.ts new file mode 100644 index 00000000000..944398ef0de --- /dev/null +++ b/packages/request/src/fetch.ts @@ -0,0 +1,30 @@ +/** + * A very basic Fetch Handler + * + * @module @ember-data/request/fetch + * @main @ember-data/request/fetch + */ + +import type { Context } from './-private/context'; + +/** + * A basic handler which onverts a request into a + * `fetch` call presuming the response to be `json`. + * + * ```ts + * import { Fetch } from '@ember-data/request/fetch'; + * + * manager.use([Fetch]); + * ``` + * + * @class Fetch + * @public + */ +export const Fetch = { + async request(context: Context) { + const response = await fetch(context.request.url!, context.request); + context.setResponse(response); + + return response.json(); + }, +}; diff --git a/packages/request/src/index.ts b/packages/request/src/index.ts new file mode 100644 index 00000000000..c49c8da4a25 --- /dev/null +++ b/packages/request/src/index.ts @@ -0,0 +1 @@ +export { RequestManager } from './-private/manager'; diff --git a/packages/tracking/package.json b/packages/tracking/package.json index 856ad316efa..cd999b44220 100644 --- a/packages/tracking/package.json +++ b/packages/tracking/package.json @@ -26,11 +26,10 @@ }, "files": [ "addon-main.js", - "dist", "addon" ], "scripts": { - "build": "rollup --config && babel ./dist --out-dir addon --plugins=./lib/transform-ext.js", + "build": "rollup --config && babel ./addon --out-dir addon --plugins=../private-build-infra/src/transforms/babel-plugin-transform-ext.js", "start": "rollup --config --watch", "prepack": "pnpm build" }, diff --git a/packages/tracking/rollup.config.mjs b/packages/tracking/rollup.config.mjs index 95c0c6152be..8a728409277 100644 --- a/packages/tracking/rollup.config.mjs +++ b/packages/tracking/rollup.config.mjs @@ -4,7 +4,7 @@ import { nodeResolve } from '@rollup/plugin-node-resolve'; const addon = new Addon({ srcDir: 'src', - destDir: 'dist', + destDir: 'addon', }); export default { diff --git a/packages/tracking/src/-private.ts b/packages/tracking/src/-private.ts index 6b1e7e215a3..ea23e5bac70 100644 --- a/packages/tracking/src/-private.ts +++ b/packages/tracking/src/-private.ts @@ -1,5 +1,16 @@ /** + * This package provides primitives that allow powerful low-level + * adjustments to change tracking notification behaviors. + * + * Typically you want to use these primitives when you want to divorce + * property accesses on EmberData provided objects from the current + * tracking context. Typically this sort of thing occurs when serializing + * tracked data to send in a request: the data itself is often ancillary + * to the thing which triggered the request in the first place and you + * would not want to re-trigger the request for any update to the data. + * * @module @ember-data/tracking + * @main @ember-data/tracking */ type OpaqueFn = (...args: unknown[]) => unknown; type Tag = { ref: null; t: boolean }; @@ -87,6 +98,8 @@ export function addTransactionCB(method: OpaqueFn): void { * * @function untracked * @public + * @static + * @for @ember-data/tracking * @param method * @returns result of invoking method */ @@ -98,9 +111,18 @@ export function untracked(method: T): ReturnType { } /** + * Run the method, subscribing to any tracked properties + * managed by EmberData that were accessed or written during + * the method's execution as per-normal but while allowing + * interleaving of reads and writes. + * + * This is useful when for instance you want to perform + * a mutation based on existing state that must be read first. * * @function transact * @public + * @static + * @for @ember-data/tracking * @param method * @returns result of invoking method */ @@ -112,9 +134,14 @@ export function transact(method: T): ReturnType { } /** + * A helpful utility for creating a new function that + * always runs in a transaction. E.G. this "memoizes" + * calling `transact(fn)`, currying args as necessary. * * @method memoTransact * @public + * @static + * @for @ember-data/tracking * @param method * @returns a function that will invoke method in a transaction with any provided args and return its result */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19a82b81114..95b8320e14c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -464,6 +464,47 @@ importers: '@ember-data/private-build-infra': injected: true + packages/request: + specifiers: + '@babel/cli': ^7.19.3 + '@babel/core': ^7.19.6 + '@babel/plugin-proposal-class-properties': ^7.18.6 + '@babel/plugin-proposal-decorators': ^7.20.0 + '@babel/plugin-transform-runtime': ^7.19.6 + '@babel/plugin-transform-typescript': ^7.20.0 + '@babel/preset-env': ^7.19.4 + '@babel/preset-typescript': ^7.18.6 + '@babel/runtime': ^7.20.0 + '@embroider/addon-dev': ^2.0.0 + '@embroider/macros': ^1.9.0 + '@rollup/plugin-babel': ^6.0.2 + '@rollup/plugin-node-resolve': ^15.0.1 + ember-cli-babel: ^7.26.11 + rollup: ^3.2.3 + tslib: ^2.4.0 + typescript: ^4.8.4 + walk-sync: ^3.0.0 + dependencies: + '@embroider/macros': 1.9.0_xahliinzuq7jqnkqqzon2ivk4y + ember-cli-babel: 7.26.11 + devDependencies: + '@babel/cli': 7.19.3_@babel+core@7.20.2 + '@babel/core': 7.20.2 + '@babel/plugin-proposal-class-properties': 7.18.6_@babel+core@7.20.2 + '@babel/plugin-proposal-decorators': 7.20.2_@babel+core@7.20.2 + '@babel/plugin-transform-runtime': 7.19.6_@babel+core@7.20.2 + '@babel/plugin-transform-typescript': 7.20.2_@babel+core@7.20.2 + '@babel/preset-env': 7.20.2_@babel+core@7.20.2 + '@babel/preset-typescript': 7.18.6_@babel+core@7.20.2 + '@babel/runtime': 7.20.1 + '@embroider/addon-dev': 2.0.0_rollup@3.3.0 + '@rollup/plugin-babel': 6.0.2_lkvu63hzbahttaf34ikyu7tiyq + '@rollup/plugin-node-resolve': 15.0.1_rollup@3.3.0 + rollup: 3.3.0 + tslib: 2.4.1 + typescript: 4.8.4 + walk-sync: 3.0.0 + packages/serializer: specifiers: '@ember-data/private-build-infra': workspace:4.9.0-alpha.14 @@ -1544,6 +1585,97 @@ importers: '@ember-data/unpublished-test-infra': injected: true + tests/request: + specifiers: + '@babel/core': ^7.19.3 + '@babel/runtime': ^7.19.3 + '@ember-data/canary-features': workspace:4.9.0-alpha.14 + '@ember-data/private-build-infra': workspace:4.9.0-alpha.14 + '@ember-data/request': workspace:4.9.0-alpha.14 + '@ember-data/unpublished-test-infra': workspace:4.9.0-alpha.14 + '@ember/edition-utils': ^1.2.0 + '@ember/optional-features': ^2.0.0 + '@ember/string': ^3.0.0 + '@ember/test-helpers': ~2.8.1 + '@glimmer/component': ^1.1.2 + '@glimmer/tracking': ^1.1.2 + broccoli-asset-rev: ^3.0.0 + ember-auto-import: ^2.4.3 + ember-cli: ~4.8.0 + ember-cli-babel: ^7.26.11 + ember-cli-blueprint-test-helpers: ^0.19.2 + ember-cli-dependency-checker: ^3.3.1 + ember-cli-htmlbars: ^6.1.1 + ember-cli-inject-live-reload: ^2.1.0 + ember-cli-sri: ^2.1.1 + ember-cli-terser: ~4.0.2 + ember-cli-test-loader: ^3.0.0 + ember-disable-prototype-extensions: ^1.1.3 + ember-export-application-global: ^2.0.1 + ember-inflector: ^4.0.2 + ember-load-initializers: ^2.1.2 + ember-maybe-import-regenerator: ^1.0.0 + ember-qunit: ^6.0.0 + ember-resolver: ^8.0.3 + ember-source: ~4.8.0 + ember-source-channel-url: ^3.0.0 + ember-try: ^2.0.0 + loader.js: ^4.7.0 + qunit: ^2.19.2 + qunit-console-grouper: ^0.3.0 + qunit-dom: ^2.0.0 + silent-error: ^1.1.1 + webpack: ^5.74.0 + devDependencies: + '@babel/core': 7.20.2 + '@babel/runtime': 7.20.1 + '@ember-data/canary-features': file:packages/canary-features + '@ember-data/private-build-infra': file:packages/private-build-infra + '@ember-data/request': file:packages/request + '@ember-data/unpublished-test-infra': file:packages/unpublished-test-infra + '@ember/edition-utils': 1.2.0 + '@ember/optional-features': 2.0.0 + '@ember/string': 3.0.0 + '@ember/test-helpers': 2.8.1_o6vra3atesdmwivzf2sf5xbdmm + '@glimmer/component': 1.1.2_@babel+core@7.20.2 + '@glimmer/tracking': 1.1.2 + broccoli-asset-rev: 3.0.0 + ember-auto-import: 2.4.3_webpack@5.75.0 + ember-cli: 4.8.0 + ember-cli-babel: 7.26.11 + ember-cli-blueprint-test-helpers: 0.19.2 + ember-cli-dependency-checker: 3.3.1_ember-cli@4.8.0 + ember-cli-htmlbars: 6.1.1 + ember-cli-inject-live-reload: 2.1.0 + ember-cli-sri: 2.1.1 + ember-cli-terser: 4.0.2 + ember-cli-test-loader: 3.0.0 + ember-disable-prototype-extensions: 1.1.3 + ember-export-application-global: 2.0.1 + ember-inflector: 4.0.2 + ember-load-initializers: 2.1.2_@babel+core@7.20.2 + ember-maybe-import-regenerator: 1.0.0 + ember-qunit: 6.0.0_qhusffjocg4cnbdwj4yxijwhva + ember-resolver: 8.0.3_@babel+core@7.20.2 + ember-source: 4.8.2_rzt62hinieo7pkbxqnerr4utxi + ember-source-channel-url: 3.0.0 + ember-try: 2.0.0 + loader.js: 4.7.0 + qunit: 2.19.3 + qunit-console-grouper: 0.3.0 + qunit-dom: 2.0.0 + silent-error: 1.1.1 + webpack: 5.75.0 + dependenciesMeta: + '@ember-data/canary-features': + injected: true + '@ember-data/private-build-infra': + injected: true + '@ember-data/request': + injected: true + '@ember-data/unpublished-test-infra': + injected: true + tests/serializer-encapsulation: specifiers: '@babel/core': ^7.19.3 @@ -2148,8 +2280,8 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.19.6 - '@babel/helper-create-class-features-plugin': 7.19.0_@babel+core@7.19.6 - '@babel/helper-plugin-utils': 7.19.0 + '@babel/helper-create-class-features-plugin': 7.20.2_@babel+core@7.19.6 + '@babel/helper-plugin-utils': 7.20.2 transitivePeerDependencies: - supports-color dev: true @@ -2161,8 +2293,8 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.20.2 - '@babel/helper-create-class-features-plugin': 7.19.0_@babel+core@7.20.2 - '@babel/helper-plugin-utils': 7.19.0 + '@babel/helper-create-class-features-plugin': 7.20.2_@babel+core@7.20.2 + '@babel/helper-plugin-utils': 7.20.2 transitivePeerDependencies: - supports-color @@ -3125,7 +3257,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.20.2 - '@babel/helper-plugin-utils': 7.19.0 + '@babel/helper-plugin-utils': 7.20.2 '@babel/helper-validator-option': 7.18.6 '@babel/plugin-transform-typescript': 7.20.2_@babel+core@7.20.2 transitivePeerDependencies: @@ -4071,7 +4203,7 @@ packages: /@types/ember-resolver/5.0.13: resolution: {integrity: sha512-pO964cAPhAaFJoS28M8+b5MzAhQ/tVuNM4GDUIAexheQat36axG2WTG8LQ5ea07MSFPesrRFk2T3z88pfvdYKA==} dependencies: - '@types/ember__object': 4.0.5_@babel+core@7.19.6 + '@types/ember__object': 4.0.5_@babel+core@7.20.2 '@types/ember__owner': 4.0.1 dev: true @@ -4105,21 +4237,21 @@ packages: /@types/ember/4.0.2: resolution: {integrity: sha512-u6YtM35NR6W601xF2NOgX4aMkF4SLIq09h68xIiRJkcRpVlNrNu5/QowbSKbflI0lUllNVKH2UcsKi/tPvAzOg==} dependencies: - '@types/ember__application': 4.0.3_@babel+core@7.20.2 - '@types/ember__array': 4.0.3_@babel+core@7.20.2 - '@types/ember__component': 4.0.11_@babel+core@7.20.2 - '@types/ember__controller': 4.0.3_@babel+core@7.20.2 + '@types/ember__application': 4.0.3_@babel+core@7.19.6 + '@types/ember__array': 4.0.3_@babel+core@7.19.6 + '@types/ember__component': 4.0.11_@babel+core@7.19.6 + '@types/ember__controller': 4.0.3_@babel+core@7.19.6 '@types/ember__debug': 4.0.2 '@types/ember__engine': 4.0.3 '@types/ember__error': 4.0.1 - '@types/ember__object': 4.0.5_@babel+core@7.20.2 + '@types/ember__object': 4.0.5_@babel+core@7.19.6 '@types/ember__polyfills': 4.0.1 - '@types/ember__routing': 4.0.12_@babel+core@7.20.2 - '@types/ember__runloop': 4.0.2_@babel+core@7.20.2 + '@types/ember__routing': 4.0.12_@babel+core@7.19.6 + '@types/ember__runloop': 4.0.2_@babel+core@7.19.6 '@types/ember__service': 4.0.1 '@types/ember__string': 3.0.10 '@types/ember__template': 4.0.1 - '@types/ember__test': 4.0.1_@babel+core@7.20.2 + '@types/ember__test': 4.0.1_@babel+core@7.19.6 '@types/ember__utils': 4.0.2 '@types/htmlbars-inline-precompile': 3.0.0 '@types/rsvp': 4.0.4 @@ -4241,7 +4373,7 @@ packages: resolution: {integrity: sha512-G6kbLaS3ke4QspHkgLlGY0t1v0G22hGavyphezZucj7LLk1N+r11w913CYkBg3cJsJD+TG2Wo4eVbgRcotvuvQ==} dependencies: '@types/ember': 4.0.2_@babel+core@7.19.6 - '@types/ember__object': 4.0.5 + '@types/ember__object': 4.0.5_@babel+core@7.19.6 transitivePeerDependencies: - '@babel/core' - supports-color @@ -4251,7 +4383,7 @@ packages: resolution: {integrity: sha512-G6kbLaS3ke4QspHkgLlGY0t1v0G22hGavyphezZucj7LLk1N+r11w913CYkBg3cJsJD+TG2Wo4eVbgRcotvuvQ==} dependencies: '@types/ember': 4.0.2_@babel+core@7.20.2 - '@types/ember__object': 4.0.5_@babel+core@7.20.2 + '@types/ember__object': 4.0.5 transitivePeerDependencies: - '@babel/core' - supports-color @@ -4261,7 +4393,7 @@ packages: resolution: {integrity: sha512-iwFf+qYBsGp9SycIb0lxGkdZPYpKxMcBoV5kCJbWyC6azuX2xPDXHx8n2lm8O9GrEFVJXfYC5bSXf33rdpy5Sw==} dependencies: '@types/ember': 4.0.2_@babel+core@7.19.6 - '@types/ember__object': 4.0.5 + '@types/ember__object': 4.0.5_@babel+core@7.19.6 transitivePeerDependencies: - '@babel/core' - supports-color @@ -4271,7 +4403,7 @@ packages: resolution: {integrity: sha512-iwFf+qYBsGp9SycIb0lxGkdZPYpKxMcBoV5kCJbWyC6azuX2xPDXHx8n2lm8O9GrEFVJXfYC5bSXf33rdpy5Sw==} dependencies: '@types/ember': 4.0.2_@babel+core@7.20.2 - '@types/ember__object': 4.0.5_@babel+core@7.20.2 + '@types/ember__object': 4.0.5 transitivePeerDependencies: - '@babel/core' - supports-color @@ -4425,7 +4557,7 @@ packages: /@types/ember__object/4.0.5: resolution: {integrity: sha512-gXrywWBwoW7J9y9yJqoZ0m1qtiyMdrEi29cJdF1xI2qOnMqaZeuSCMYaPQMsyq52/YnVIG2EnGzo6eUD57J4Nw==} dependencies: - '@types/ember': 4.0.2_@babel+core@7.19.6 + '@types/ember': 4.0.2_@babel+core@7.20.2 '@types/rsvp': 4.0.4 dev: true @@ -4460,7 +4592,7 @@ packages: /@types/ember__routing/4.0.12: resolution: {integrity: sha512-zxPS43JP8/dEmNrSucN5KzTvOm+JUrbFGWsJ1m5a395FwxYbpgs7JujV0JWl+eVhnCh/PmsNcCdJT16+jouktQ==} dependencies: - '@types/ember': 4.0.2_@babel+core@7.20.2 + '@types/ember': 4.0.2_@babel+core@7.19.6 '@types/ember__controller': 4.0.3 '@types/ember__object': 4.0.5 '@types/ember__service': 4.0.1 @@ -4664,7 +4796,7 @@ packages: /@types/glob/8.0.0: resolution: {integrity: sha512-l6NQsDDyQUVeoTynNpC9uRvCUint/gSUXQA2euwmTuWGvPY5LSDUu6tkCtJB2SvGQlJQzLaKqcGZP4//7EDveA==} dependencies: - '@types/minimatch': 3.0.5 + '@types/minimatch': 5.1.2 '@types/node': 18.11.7 /@types/htmlbars-inline-precompile/3.0.0: @@ -4699,7 +4831,6 @@ packages: /@types/minimatch/5.1.2: resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} - dev: true /@types/node/18.11.7: resolution: {integrity: sha512-LhFTglglr63mNXUSRYD8A+ZAIu5sFqNJ4Y2fPuY7UlrySJH87rRRlhtVmMHplmfk5WkoJGmDjE9oiTfyX94CpQ==} @@ -16535,6 +16666,18 @@ packages: - supports-color - webpack + file:packages/request: + resolution: {directory: packages/request, type: directory} + name: '@ember-data/request' + version: 4.9.0-alpha.14 + engines: {node: 14.* || 16.* || >= 18} + dependencies: + '@embroider/macros': 1.9.0_xahliinzuq7jqnkqqzon2ivk4y + ember-cli-babel: 7.26.11 + transitivePeerDependencies: + - supports-color + dev: true + file:packages/serializer: resolution: {directory: packages/serializer, type: directory} name: '@ember-data/serializer' diff --git a/root-tsconfig.json b/root-tsconfig.json index 6b5c3c07dc8..47fa1ddb7b9 100644 --- a/root-tsconfig.json +++ b/root-tsconfig.json @@ -35,6 +35,10 @@ "@ember-data/store/*": ["packages/store/addon/*"], "@ember-data/tracking": ["packages/tracking/src"], "@ember-data/tracking/*": ["packages/tracking/src/*"], + "@ember-data/request": ["packages/request/src"], + "@ember-data/request/*": ["packages/request/src/*"], + "@ember-data/experimental-preview-types": ["packages/experimental-preview-types/src"], + "@ember-data/experimental-preview-types/*": ["packages/experimental-preview-types/src/*"], "@ember-data/debug": ["packages/debug/addon"], "@ember-data/debug/*": ["packages/debug/addon/*"], "@ember-data/model": ["packages/model/addon"], @@ -60,6 +64,8 @@ "main-test-app/*": ["tests/main/app/*"], "graph-test-app/tests/*": ["tests/graph/tests/*"], "graph-test-app/*": ["tests/graph/app/*"], + "request-test-app/tests/*": ["tests/request/tests/*"], + "request-test-app/*": ["tests/request/app/*"], "*": ["@types/*", "packages/fastboot-test-app/types/*"], "ember-inflector": ["tests/main/node_modules/ember-inflector","packages/-ember-data/node_modules/ember-inflector"] } @@ -70,6 +76,9 @@ "packages/**/app/**/*", "packages/**/addon/**/*", "packages/**/tests/**/*", + "packages/tracking/src/**/*", + "packages/request/src/**/*", + "packages/experimental-preview-types/src/**/*", "packages/fastboot-test-app/types/**/*", "packages/**/test-support/**/*", "packages/**/addon-test-support/**/*", diff --git a/tests/docs/fixtures/expected.js b/tests/docs/fixtures/expected.js index 3f12f9d86f6..dcb336900e9 100644 --- a/tests/docs/fixtures/expected.js +++ b/tests/docs/fixtures/expected.js @@ -9,11 +9,14 @@ module.exports = { '@ember-data/deprecations', '@ember-data/model', '@ember-data/record-data', + '@ember-data/request', + '@ember-data/request/fetch', '@ember-data/serializer', '@ember-data/serializer/json', '@ember-data/serializer/json-api', '@ember-data/serializer/rest', '@ember-data/store', + '@ember-data/tracking', 'ember-data-overview', ], classitems: [ @@ -438,5 +441,10 @@ module.exports = { '(public) @ember-data/model PromiseManyArray#then', '(public) @ember-data/store ManyArray#links', '(public) @ember-data/store Store#identifierCache', + '(public) @ember-data/tracking @ember-data/tracking#memoTransact', + '(public) @ember-data/tracking @ember-data/tracking#transact', + '(public) @ember-data/tracking @ember-data/tracking#untracked', + '(public) @ember-data/request RequestManager#request', + '(public) @ember-data/request RequestManager#use', ], }; diff --git a/tests/request/README.md b/tests/request/README.md new file mode 100644 index 00000000000..21e3f562a0e --- /dev/null +++ b/tests/request/README.md @@ -0,0 +1,3 @@ +# request-tests + +Provides testing for the RequestManager diff --git a/tests/request/app/app.ts b/tests/request/app/app.ts new file mode 100644 index 00000000000..1f39476ae86 --- /dev/null +++ b/tests/request/app/app.ts @@ -0,0 +1,16 @@ +import Application from '@ember/application'; + +import loadInitializers from 'ember-load-initializers'; + +import config from './config/environment'; +import Resolver from './resolver'; + +class App extends Application { + modulePrefix = config.modulePrefix; + podModulePrefix = config.podModulePrefix; + Resolver = Resolver; +} + +loadInitializers(App, config.modulePrefix); + +export default App; diff --git a/tests/request/app/config/environment.d.ts b/tests/request/app/config/environment.d.ts new file mode 100644 index 00000000000..f989849605d --- /dev/null +++ b/tests/request/app/config/environment.d.ts @@ -0,0 +1,16 @@ +export default config; + +/** + * Type declarations for + * import config from './config/environment' + * + * For now these need to be managed by the developer + * since different ember addons can materialize new entries. + */ +declare const config: { + environment: 'production' | 'development' | 'testing'; + modulePrefix: string; + podModulePrefix: string; + locationType: string; + rootURL: string; +}; diff --git a/tests/request/app/index.html b/tests/request/app/index.html new file mode 100644 index 00000000000..e46e09bc3cb --- /dev/null +++ b/tests/request/app/index.html @@ -0,0 +1,25 @@ + + + + + + EmberData RequestManager Test App + + + + {{content-for "head"}} + + + + + {{content-for "head-footer"}} + + + {{content-for "body"}} + + + + + {{content-for "body-footer"}} + + diff --git a/tests/request/app/resolver.ts b/tests/request/app/resolver.ts new file mode 100644 index 00000000000..2fb563d6c04 --- /dev/null +++ b/tests/request/app/resolver.ts @@ -0,0 +1,3 @@ +import Resolver from 'ember-resolver'; + +export default Resolver; diff --git a/tests/request/app/router.ts b/tests/request/app/router.ts new file mode 100644 index 00000000000..7525f056ab3 --- /dev/null +++ b/tests/request/app/router.ts @@ -0,0 +1,12 @@ +import EmberRouter from '@ember/routing/router'; + +import config from './config/environment'; + +const Router = EmberRouter.extend({ + location: config.locationType, + rootURL: config.rootURL, +}); + +Router.map(function () {}); + +export default Router; diff --git a/tests/request/app/styles/app.css b/tests/request/app/styles/app.css new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/request/app/templates/.gitkeep b/tests/request/app/templates/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/request/app/templates/application.hbs b/tests/request/app/templates/application.hbs new file mode 100644 index 00000000000..578920ea827 --- /dev/null +++ b/tests/request/app/templates/application.hbs @@ -0,0 +1,7 @@ +

diff --git a/tests/request/config/environment.js b/tests/request/config/environment.js new file mode 100644 index 00000000000..0caf91a5989 --- /dev/null +++ b/tests/request/config/environment.js @@ -0,0 +1,51 @@ +'use strict'; + +module.exports = function (environment) { + let ENV = { + modulePrefix: 'request-test-app', + environment, + rootURL: '/', + locationType: 'auto', + EmberENV: { + FEATURES: { + // Here you can enable experimental features on an ember canary build + // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true + }, + EXTEND_PROTOTYPES: { + // Prevent Ember Data from overriding Date.parse. + Date: false, + }, + }, + + APP: { + // Here you can pass flags/options to your application instance + // when it is created + }, + }; + + if (environment === 'development') { + // ENV.APP.LOG_RESOLVER = true; + // ENV.APP.LOG_ACTIVE_GENERATION = true; + // ENV.APP.LOG_TRANSITIONS = true; + // ENV.APP.LOG_TRANSITIONS_INTERNAL = true; + // ENV.APP.LOG_VIEW_LOOKUPS = true; + } + + if (environment === 'test') { + // Testem prefers this... + ENV.locationType = 'none'; + + // keep test console output quieter + ENV.APP.LOG_ACTIVE_GENERATION = false; + ENV.APP.LOG_VIEW_LOOKUPS = false; + + ENV.APP.rootElement = '#ember-testing'; + ENV.APP.autoboot = false; + } + + if (environment === 'production') { + // here you can enable a production-specific feature + } + + return ENV; +}; diff --git a/tests/request/config/optional-features.json b/tests/request/config/optional-features.json new file mode 100644 index 00000000000..b26286e2ecd --- /dev/null +++ b/tests/request/config/optional-features.json @@ -0,0 +1,6 @@ +{ + "application-template-wrapper": false, + "default-async-observers": true, + "jquery-integration": false, + "template-only-glimmer-components": true +} diff --git a/tests/request/config/targets.js b/tests/request/config/targets.js new file mode 100644 index 00000000000..b6756da2517 --- /dev/null +++ b/tests/request/config/targets.js @@ -0,0 +1,13 @@ +'use strict'; + +let browsers = ['last 1 Chrome versions', 'last 1 Firefox versions', 'last 1 Safari versions']; +const isProd = process.env.EMBER_ENV === 'production'; + +if (isProd) { + browsers = ['last 2 Chrome versions', 'last 2 Firefox versions', 'Safari 12', 'last 2 Edge versions']; +} + +module.exports = { + browsers, + node: 'current', +}; diff --git a/tests/request/ember-cli-build.js b/tests/request/ember-cli-build.js new file mode 100644 index 00000000000..c68455ff058 --- /dev/null +++ b/tests/request/ember-cli-build.js @@ -0,0 +1,41 @@ +/* eslint-disable node/no-unpublished-require */ +'use strict'; + +const EmberApp = require('ember-cli/lib/broccoli/ember-app'); + +module.exports = function (defaults) { + const compatWith = process.env.EMBER_DATA_FULL_COMPAT ? '99.0' : null; + let app = new EmberApp(defaults, { + emberData: { + compatWith, + }, + babel: { + // this ensures that the same build-time code stripping that is done + // for library packages is also done for our tests and dummy app + plugins: [ + ...require('@ember-data/private-build-infra/src/debug-macros')({ + compatWith, + debug: {}, + features: {}, + deprecations: {}, + }), + ], + }, + 'ember-cli-babel': { + throwUnlessParallelizable: true, + enableTypeScriptTransform: true, + }, + 'ember-cli-terser': { + exclude: ['assets/dummy.js', 'assets/tests.js', 'assets/test-support.js'], + }, + }); + + /* + This build file specifies the options for the dummy test app of this + addon, located in `/tests/dummy` + This build file does *not* influence how the addon or the app using it + behave. You most likely want to be modifying `./index.js` or app's build file + */ + + return app.toTree(); +}; diff --git a/tests/request/package.json b/tests/request/package.json new file mode 100644 index 00000000000..4d945f86dde --- /dev/null +++ b/tests/request/package.json @@ -0,0 +1,87 @@ +{ + "name": "request-test-app", + "version": "4.9.0-alpha.14", + "private": true, + "description": "Provides tests for the RequestManager", + "keywords": [], + "repository": { + "type": "git", + "url": "git+ssh://git@github.com:emberjs/data.git", + "directory": "tests/request" + }, + "license": "MIT", + "author": "", + "directories": { + "test": "tests" + }, + "scripts": { + "build": "ember build", + "start": "ember test --port=0 --serve --no-launch", + "test": "ember test --test-port=0" + }, + "dependenciesMeta": { + "@ember-data/request": { + "injected": true + }, + "@ember-data/canary-features": { + "injected": true + }, + "@ember-data/private-build-infra": { + "injected": true + }, + "@ember-data/unpublished-test-infra": { + "injected": true + } + }, + "devDependencies": { + "@babel/core": "^7.19.3", + "@babel/runtime": "^7.19.3", + "@ember-data/canary-features": "workspace:4.9.0-alpha.14", + "@ember-data/request": "workspace:4.9.0-alpha.14", + "@ember-data/private-build-infra": "workspace:4.9.0-alpha.14", + "@ember-data/unpublished-test-infra": "workspace:4.9.0-alpha.14", + "@ember/edition-utils": "^1.2.0", + "@ember/optional-features": "^2.0.0", + "@ember/string": "^3.0.0", + "@ember/test-helpers": "~2.8.1", + "@glimmer/component": "^1.1.2", + "@glimmer/tracking": "^1.1.2", + "broccoli-asset-rev": "^3.0.0", + "ember-auto-import": "^2.4.3", + "ember-cli": "~4.8.0", + "ember-cli-babel": "^7.26.11", + "ember-cli-blueprint-test-helpers": "^0.19.2", + "ember-cli-dependency-checker": "^3.3.1", + "ember-cli-htmlbars": "^6.1.1", + "ember-cli-inject-live-reload": "^2.1.0", + "ember-cli-sri": "^2.1.1", + "ember-cli-terser": "~4.0.2", + "ember-cli-test-loader": "^3.0.0", + "ember-disable-prototype-extensions": "^1.1.3", + "ember-export-application-global": "^2.0.1", + "ember-inflector": "^4.0.2", + "ember-load-initializers": "^2.1.2", + "ember-maybe-import-regenerator": "^1.0.0", + "ember-qunit": "^6.0.0", + "ember-resolver": "^8.0.3", + "ember-source": "~4.8.0", + "ember-source-channel-url": "^3.0.0", + "ember-try": "^2.0.0", + "loader.js": "^4.7.0", + "qunit": "^2.19.2", + "qunit-console-grouper": "^0.3.0", + "qunit-dom": "^2.0.0", + "silent-error": "^1.1.1", + "webpack": "^5.74.0" + }, + "ember": { + "edition": "octane" + }, + "engines": { + "node": "^14.8.0 || 16.* || >= 18.*" + }, + "volta": { + "extends": "../../package.json" + }, + "packageManager": "pnpm@7.15.0" +} diff --git a/tests/request/public/assets/demo-fetch.json b/tests/request/public/assets/demo-fetch.json new file mode 100644 index 00000000000..0284900922d --- /dev/null +++ b/tests/request/public/assets/demo-fetch.json @@ -0,0 +1,6 @@ +{ + "data": { + "type": "example", + "id": "1" + } +} diff --git a/tests/request/testem.js b/tests/request/testem.js new file mode 100644 index 00000000000..f4b8dedaa70 --- /dev/null +++ b/tests/request/testem.js @@ -0,0 +1,30 @@ +/* eslint-disable node/no-unpublished-require */ +const customDotReporter = require('@ember-data/unpublished-test-infra/src/testem/custom-dot-reporter'); + +// eslint-disable-next-line no-console +console.log(`\n\nLaunching with ${process.env.TESTEM_CI_LAUNCHER || 'Chrome'}\n\n`); + +module.exports = { + test_page: 'tests/index.html?hidepassed&nocontainer', + disable_watching: true, + reporter: customDotReporter, + launch_in_ci: [process.env.TESTEM_CI_LAUNCHER || 'Chrome'], + launch_in_dev: ['Chrome'], + browser_start_timeout: 120, + browser_args: { + Chrome: { + ci: [ + '--headless', + '--disable-dev-shm-usage', + '--disable-software-rasterizer', + '--mute-audio', + '--remote-debugging-port=0', + '--window-size=1440,900', + '--no-sandbox', + ], + }, + }, + Firefox: { + ci: ['-headless', '-width 1440', '-height 900'], + }, +}; diff --git a/tests/request/tests/.gitkeep b/tests/request/tests/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/request/tests/index.html b/tests/request/tests/index.html new file mode 100644 index 00000000000..46753b4f41c --- /dev/null +++ b/tests/request/tests/index.html @@ -0,0 +1,41 @@ + + + + + + RequestManager Tests + + + + {{content-for "head"}} + {{content-for "test-head"}} + + + + + + {{content-for "head-footer"}} + {{content-for "test-head-footer"}} + + + {{content-for "body"}} + {{content-for "test-body"}} + +
+
+
+
+
+
+ + + + + + + + {{content-for "body-footer"}} + {{content-for "test-body-footer"}} + + + diff --git a/tests/request/tests/integration/abort-test.ts b/tests/request/tests/integration/abort-test.ts new file mode 100644 index 00000000000..bbfb0b8e433 --- /dev/null +++ b/tests/request/tests/integration/abort-test.ts @@ -0,0 +1,139 @@ +import { module, test } from 'qunit'; + +import { RequestManager } from '@ember-data/request'; +import type { Context } from '@ember-data/request/-private/context'; +import type { Future, Handler, NextFn, RequestInfo } from '@ember-data/request/-private/types'; + +module('RequestManager | Abort', function () { + test('We can abort requests', async function (assert) { + assert.expect(2); + const manager = new RequestManager(); + const handler: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + assert.true(context.request.signal instanceof AbortSignal, 'we receive the abort signal'); + const result = await fetch(context.request.url!, context.request); + + return result.json() as T; + }, + }; + manager.use([handler]); + + const future = manager.request({ url: '../assets/demo-fetch.json' }); + + try { + future.abort(); + await future; + assert.ok(false, 'aborting a request should result in the promise rejecting'); + } catch (e) { + assert.true(e instanceof Error); + } + }); + + test('We can abort requests called via next', async function (assert) { + assert.expect(3); + const manager = new RequestManager(); + const handler1: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + const future = next(context.request); + assert.true(context.request.signal instanceof AbortSignal, 'we receive the abort signal in handler1'); + + return (await future).data; + }, + }; + const handler2: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + assert.true(context.request.signal instanceof AbortSignal, 'we receive the abort signal in handler2'); + const result = await fetch(context.request.url!, context.request); + + return result.json() as T; + }, + }; + manager.use([handler1, handler2]); + + const future = manager.request({ url: '../assets/demo-fetch.json' }); + + try { + future.abort(); + await future; + assert.ok(false, 'aborting a request should result in the promise rejecting'); + } catch (e) { + assert.true(e instanceof Error); + } + }); + + test("We can abort tee'd requests called via next", async function (assert) { + assert.expect(5); + const manager = new RequestManager(); + const handler1: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + const future = next(context.request); + const future2 = next(context.request); + const future3 = next(context.request); + assert.true(context.request.signal instanceof AbortSignal, 'we receive the abort signal in handler1'); + await Promise.all([future, future2, future3]); + + return (await future).data; + }, + }; + const handler2: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + assert.true(context.request.signal instanceof AbortSignal, 'we receive the abort signal in handler2'); + const result = await fetch(context.request.url!, context.request); + + return result.json() as T; + }, + }; + manager.use([handler1, handler2]); + + const future = manager.request({ url: '../assets/demo-fetch.json' }); + + try { + future.abort(); + await future; + assert.ok(false, 'aborting a request should result in the promise rejecting'); + } catch (e) { + assert.true(e instanceof Error); + } + }); + + test('We fully abort even when a handler does not pass along our signal', async function (assert) { + assert.expect(3); + const manager = new RequestManager(); + const handler1: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + const future = next(context.request); + assert.true(context.request.signal instanceof AbortSignal, 'we receive the abort signal in handler1'); + + return (await future).data; + }, + }; + const handler2: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + assert.true(context.request.signal instanceof AbortSignal, 'we receive the abort signal in handler2'); + const request: RequestInfo = Object.assign({}, context.request) as RequestInfo; + delete request.signal; + const result = await fetch(request.url!, request); + + return result.json() as T; + }, + }; + manager.use([handler1, handler2]); + + const future = manager.request({ url: '../assets/demo-fetch.json' }); + + try { + future.abort(); + await future; + assert.ok(false, 'aborting a request should result in the promise rejecting'); + } catch (e) { + assert.true(e instanceof Error); + } + }); +}); diff --git a/tests/request/tests/integration/custom-abort-test.ts b/tests/request/tests/integration/custom-abort-test.ts new file mode 100644 index 00000000000..5f76339b0eb --- /dev/null +++ b/tests/request/tests/integration/custom-abort-test.ts @@ -0,0 +1,148 @@ +import { module, test } from 'qunit'; + +import { RequestManager } from '@ember-data/request'; +import type { Context } from '@ember-data/request/-private/context'; +import type { Future, Handler, NextFn, RequestInfo } from '@ember-data/request/-private/types'; + +module('RequestManager | Custom Abort', function () { + test('We can abort requests', async function (assert) { + assert.expect(4); + const manager = new RequestManager(); + const controller = new AbortController(); + const handler: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + assert.true(context.request.signal instanceof AbortSignal, 'we receive the abort signal'); + assert.strictEqual(context.request.signal, controller.signal, 'we receive the correct signal'); + // @ts-expect-error + assert.strictEqual(context.request.controller, undefined, 'we do not receive the controller'); + const result = await fetch(context.request.url!, context.request); + + return result.json() as T; + }, + }; + manager.use([handler]); + + const future = manager.request({ url: '../assets/demo-fetch.json', controller }); + + try { + future.abort(); + await future; + assert.ok(false, 'aborting a request should result in the promise rejecting'); + } catch (e) { + assert.true(e instanceof Error); + } + }); + + test('We can abort requests called via next', async function (assert) { + assert.expect(7); + const manager = new RequestManager(); + const controller = new AbortController(); + const handler1: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + const future = next(context.request); + assert.true(context.request.signal instanceof AbortSignal, 'we receive the abort signal in handler1'); + assert.strictEqual(context.request.signal, controller.signal, 'we receive the correct signal'); + // @ts-expect-error + assert.strictEqual(context.request.controller, undefined, 'we do not receive the controller'); + return (await future).data; + }, + }; + const handler2: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + assert.true(context.request.signal instanceof AbortSignal, 'we receive the abort signal in handler2'); + assert.strictEqual(context.request.signal, controller.signal, 'we receive the correct signal'); + // @ts-expect-error + assert.strictEqual(context.request.controller, undefined, 'we do not receive the controller'); + const result = await fetch(context.request.url!, context.request); + + return result.json() as T; + }, + }; + manager.use([handler1, handler2]); + + const future = manager.request({ url: '../assets/demo-fetch.json', controller }); + + try { + future.abort(); + await future; + assert.ok(false, 'aborting a request should result in the promise rejecting'); + } catch (e) { + assert.true(e instanceof Error); + } + }); + + test('We can provide a different abort controller from a handler', async function (assert) { + assert.expect(3); + const manager = new RequestManager(); + const handler1: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + assert.true(context.request.signal instanceof AbortSignal, 'we receive the abort signal in handler1'); + const controller = new AbortController(); + const future = next(Object.assign({ controller }, context.request, { signal: controller.signal })); + + return (await future).data; + }, + }; + const handler2: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + assert.true(context.request.signal instanceof AbortSignal, 'we receive the abort signal in handler2'); + const result = await fetch(context.request.url!, context.request); + + return result.json() as T; + }, + }; + manager.use([handler1, handler2]); + + const future = manager.request({ url: '../assets/demo-fetch.json' }); + + try { + future.abort(); + await future; + assert.ok(false, 'aborting a request should result in the promise rejecting'); + } catch (e) { + assert.true(e instanceof Error); + } + }); + + test('We fully abort even when a handler does not pass along our signal', async function (assert) { + assert.expect(3); + const manager = new RequestManager(); + const handler1: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + const controller = new AbortController(); + const future = next(Object.assign({ controller }, context.request)); + assert.true(context.request.signal instanceof AbortSignal, 'we receive the abort signal in handler1'); + + return (await future).data; + }, + }; + const handler2: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + assert.true(context.request.signal instanceof AbortSignal, 'we receive the abort signal in handler2'); + const request: RequestInfo = Object.assign({}, context.request) as RequestInfo; + delete request.signal; + const result = await fetch(request.url!, request); + + return result.json() as T; + }, + }; + manager.use([handler1, handler2]); + + const future = manager.request({ url: '../assets/demo-fetch.json' }); + + try { + future.abort(); + await future; + assert.ok(false, 'aborting a request should result in the promise rejecting'); + } catch (e) { + assert.true(e instanceof Error); + } + }); +}); diff --git a/tests/request/tests/integration/error-propagation-test.ts b/tests/request/tests/integration/error-propagation-test.ts new file mode 100644 index 00000000000..6c5ee328b17 --- /dev/null +++ b/tests/request/tests/integration/error-propagation-test.ts @@ -0,0 +1,131 @@ +import { module, test } from 'qunit'; + +import { RequestManager } from '@ember-data/request'; +import type { Context } from '@ember-data/request/-private/context'; +import type { Future, Handler, NextFn, StructuredErrorDocument } from '@ember-data/request/-private/types'; + +function isErrorDoc(e: Error | unknown | StructuredErrorDocument): e is StructuredErrorDocument { + return Boolean(e && e instanceof Error && 'request' in e); +} +module('RequestManager | Error Propagation', function () { + test('Errors thrown by a handler are catchable by the preceding handler', async function (assert) { + assert.expect(4); + const manager = new RequestManager(); + const catchingHandler: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + assert.ok(true, 'catching handler triggered'); + try { + // await to catch, else error is curried + return await next(context.request); + } catch (e) { + assert.strictEqual((e as Error).message, 'Oops!', 'We caught the error'); + return 'We are happy' as T; + } + }, + }; + const throwingHandler: Handler = { + request(context: Context, next: NextFn) { + assert.ok(true, 'throwing handler triggered'); + throw new Error('Oops!'); + }, + }; + manager.use([catchingHandler, throwingHandler]); + const { data } = await manager.request({ url: '/wat' }); + assert.strictEqual(data, 'We are happy', 'we caught and handled the error'); + }); + + test('Errors thrown by a handler curry the request properly', async function (assert) { + assert.expect(4); + const manager = new RequestManager(); + const curryingHandler: Handler = { + request(context: Context, next: NextFn): Promise | Future { + assert.ok(true, 'catching handler triggered'); + return next({ url: '/curried' }); + }, + }; + const throwingHandler: Handler = { + request(context: Context, next: NextFn) { + assert.ok(true, 'throwing handler triggered'); + throw new Error('Oops!'); + }, + }; + manager.use([curryingHandler, throwingHandler]); + try { + await manager.request({ url: '/initial' }); + assert.ok(false, 'we should throw'); + } catch (e) { + assert.true(e instanceof Error, 'we throw an error'); + + if (isErrorDoc(e)) { + assert.deepEqual(e.request, { url: '/initial' }, 'we curried the request properly'); + } + } + }); + + test('The `request` and `response` on errors is updated correctly when an error is not caught by the preceding handler', async function (assert) { + assert.expect(4); + const manager = new RequestManager(); + const catchingHandler: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + assert.ok(true, 'catching handler triggered'); + return await next({ url: '/curried' }); + }, + }; + const throwingHandler: Handler = { + request(context: Context, next: NextFn) { + assert.ok(true, 'throwing handler triggered'); + throw new Error('Oops!'); + }, + }; + manager.use([catchingHandler, throwingHandler]); + try { + await manager.request({ url: '/initial' }); + assert.ok(false, 'we should throw'); + } catch (e) { + assert.true(e instanceof Error, 'we throw an error'); + + if (isErrorDoc(e)) { + assert.deepEqual(e.request, { url: '/initial' }, 'we curried the request properly'); + } + } + }); + + test('Error documents are meaningfully serializable', async function (assert) { + assert.expect(5); + const manager = new RequestManager(); + const catchingHandler: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + assert.ok(true, 'catching handler triggered'); + return await next({ url: '/curried' }); + }, + }; + const throwingHandler: Handler = { + request(context: Context, next: NextFn) { + assert.ok(true, 'throwing handler triggered'); + throw new Error('Oops!'); + }, + }; + manager.use([catchingHandler, throwingHandler]); + try { + await manager.request({ url: '/initial' }); + assert.ok(false, 'we should throw'); + } catch (e) { + assert.true(e instanceof Error, 'we throw an error'); + + if (isErrorDoc(e)) { + assert.deepEqual(e.request, { url: '/initial' }, 'we curried the request properly'); + } + + const serialized = JSON.stringify(e); + const hydrated = JSON.parse(serialized) as StructuredErrorDocument; + assert.deepEqual( + hydrated, + { request: { url: '/initial' }, response: null, error: 'Oops!' } as StructuredErrorDocument, + `meaningfully serialized as ${serialized}` + ); + } + }); +}); diff --git a/tests/request/tests/integration/graceful-dev-errors-test.ts b/tests/request/tests/integration/graceful-dev-errors-test.ts new file mode 100644 index 00000000000..6496785756e --- /dev/null +++ b/tests/request/tests/integration/graceful-dev-errors-test.ts @@ -0,0 +1,295 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ + +import { module, test } from 'qunit'; + +import { RequestManager } from '@ember-data/request'; +import type { Context } from '@ember-data/request/-private/context'; +import type { Handler, NextFn } from '@ember-data/request/-private/types'; + +module('RequestManager | Graceful Errors', function () { + test('We error meaningfully for `.use()`', function (assert) { + const manager = new RequestManager(); + const handler = { + request() { + return Promise.resolve(); + }, + }; + try { + // @ts-ignore-error + manager.use(handler); + assert.ok(false, 'we should error when not passing an array'); + } catch (e: unknown) { + assert.true(e instanceof Error, 'We throw an error'); + assert.true( + /`RequestManager.use\(\)` expects an array of handlers/.test((e as Error).message), + `${(e as Error).message} does not match the expected error` + ); + } + }); + + test('We error meaningfully if handlers are registered ex-post-facto', async function (assert) { + const manager = new RequestManager(); + const handler = { + request() { + return Promise.resolve('hello' as T); + }, + }; + // @ts-expect-error + manager.use([handler]); + await manager.request({ url: '/wat' }); + + try { + // @ts-ignore-error + manager.use(handler); + assert.ok(false, 'we should error when not passing an array'); + } catch (e: unknown) { + assert.true(e instanceof Error, 'We throw an error'); + assert.strictEqual( + `Cannot add a Handler to a RequestManager after a request has been made`, + (e as Error).message, + `${(e as Error).message} does not match the expected error` + ); + } + }); + + test('We error meaningfully if a handler does not implement request', function (assert) { + const manager = new RequestManager(); + const handler = { + request() { + return Promise.resolve(); + }, + }; + try { + // @ts-ignore-error + manager.use(handler); + assert.ok(false, 'we should error when not passing an array'); + } catch (e: unknown) { + assert.true(e instanceof Error, 'We throw an error'); + assert.true( + /`RequestManager.use\(\)` expects an array of handlers/.test((e as Error).message), + `${(e as Error).message} does not match the expected error` + ); + } + }); + + test('We error meaningfully if a handler does not return a promise', async function (assert) { + const manager = new RequestManager(); + const handler: Handler = { + request() { + return 'hello' as T; + }, + }; + // TODO figure out why Handler is acceptable here + // despite it not returning a Promise + manager.use([handler]); + + try { + await manager.request({ url: '/wat' }); + assert.ok(false, 'we should error when the handler returns undefined'); + } catch (e: unknown) { + assert.true(e instanceof Error, 'We throw an error'); + assert.strictEqual( + `Expected handler.request to return a promise, instead received a synchronous value.`, + (e as Error).message, + `${(e as Error).message} does not match the expected error` + ); + } + }); + + test('We error meaningfully if a handler returns undefined', async function (assert) { + const manager = new RequestManager(); + const handler = { + request() { + return; + }, + }; + // @ts-expect-error + manager.use([handler]); + + try { + await manager.request({ url: '/wat' }); + assert.ok(false, 'we should error when the handler returns undefined'); + } catch (e: unknown) { + assert.true(e instanceof Error, 'We throw an error'); + assert.strictEqual( + `Expected handler.request to return a promise, instead received undefined.`, + (e as Error).message, + `${(e as Error).message} does not match the expected error` + ); + } + }); + + test('We error meaningfully for empty requests', async function (assert) { + const manager = new RequestManager(); + const handler: Handler = { + request(_context: Context, _next: NextFn): Promise { + return Promise.resolve('done' as T); + }, + }; + manager.use([handler]); + + try { + // @ts-expect-error + await manager.request(); + assert.ok(false, 'we should error when the request is missing'); + } catch (e: unknown) { + assert.true(e instanceof Error, 'We throw an error when the request is missing'); + assert.strictEqual( + (e as Error).message, + 'Expected RequestManager.request() to be called with a request, but none was provided.', + `Expected: ${(e as Error).message} - to match the expected error` + ); + } + + try { + // @ts-expect-error + await manager.request([]); + assert.ok(false, 'we should error when the request is not an object'); + } catch (e: unknown) { + assert.true(e instanceof Error, 'We throw an error when the request is not an object'); + assert.strictEqual( + (e as Error).message, + 'The `request` passed to `RequestManager.request()` should be an object, received `array`', + `Expected: ${(e as Error).message} - to match the expected error` + ); + } + + try { + await manager.request({}); + assert.ok(false, 'we should error when the request has no keys'); + } catch (e: unknown) { + assert.true(e instanceof Error, 'We throw an error when the request has no keys'); + assert.strictEqual( + (e as Error).message, + 'The `request` passed to `RequestManager.request()` was empty (`{}`). Requests need at least one valid key.', + `Expected: ${(e as Error).message} - to match the expected error` + ); + } + }); + + test('We error meaningfully for no handlers being present', async function (assert) { + const manager = new RequestManager(); + + try { + await manager.request({ url: '/wat' }); + assert.ok(false, 'we should error when no handler is present'); + } catch (e: unknown) { + assert.true(e instanceof Error, 'We throw an error'); + assert.strictEqual( + (e as Error).message, + `No handler was able to handle this request.`, + `Expected ${(e as Error).message} to match the expected error` + ); + } + }); + + test('We error meaningfully for invalid next', async function (assert) { + const manager = new RequestManager(); + const handler = { + request(req: Context, next: NextFn) { + return next(req.request); + }, + }; + manager.use([handler]); + try { + await manager.request({ url: '/wat' }); + assert.ok(false, 'we should error when no handler is present'); + } catch (e: unknown) { + assert.true(e instanceof Error, 'We throw an error'); + assert.strictEqual( + (e as Error).message, + `No handler was able to handle this request.`, + `Expected ${(e as Error).message} to match the expected error` + ); + } + }); + + test('We error meaningfully for misshapen requests', async function (assert) { + const manager = new RequestManager(); + const handler: Handler = { + request(_context: Context, _next: NextFn): Promise { + return Promise.resolve('done' as T); + }, + }; + manager.use([handler]); + + try { + await manager.request({ + // @ts-expect-error + url: true, + // @ts-expect-error + data: new Set(), + // @ts-expect-error + options: [], + // @ts-expect-error + cache: 'bogus', + // @ts-expect-error + credentials: 'never', + // @ts-expect-error + destination: 'space', + // @ts-expect-error + headers: new Map(), + // @ts-expect-error + integrity: false, + // @ts-expect-error + keepalive: 'yes', + method: 'get', + // @ts-expect-error + mode: 'find-out', + // @ts-expect-error + redirect: 'of course', + // @ts-expect-error + referrer: null, + // @ts-expect-error + referrerPolicy: 'do-whatever', + }); + assert.ok(false, 'we should error when the handler returns undefined'); + } catch (e: unknown) { + assert.true(e instanceof Error, 'We throw an error'); + assert.strictEqual( + `Invalid Request passed to \`RequestManager.request()\`. + +The following issues were found: + +\tInvalidValue: key url should be a non-empty string, received boolean +\tInvalidValue: key options should be an object +\tInvalidValue: key cache should be a one of 'default', 'force-cache', 'no-cache', 'no-store', 'only-if-cached', 'reload', received bogus +\tInvalidValue: key credentials should be a one of 'include', 'omit', 'same-origin', received never +\tInvalidValue: key destination should be a one of '', 'object', 'audio', 'audioworklet', 'document', 'embed', 'font', 'frame', 'iframe', 'image', 'manifest', 'paintworklet', 'report', 'script', 'sharedworker', 'style', 'track', 'video', 'worker', 'xslt', received space +\tInvalidValue: key headers should be an instance of Headers, received map +\tInvalidValue: key integrity should be a non-empty string, received boolean +\tInvalidValue: key keepalive should be a boolean, received string +\tInvalidValue: key method should be a one of 'GET', 'PUT', 'PATCH', 'DELETE', 'POST', 'OPTIONS', received get +\tInvalidValue: key mode should be a one of 'same-origin', 'cors', 'navigate', 'no-cors', received find-out +\tInvalidValue: key redirect should be a one of 'error', 'follow', 'manual', received of course +\tInvalidValue: key referrer should be a non-empty string, received object +\tInvalidValue: key referrerPolicy should be a one of '', 'same-origin', 'no-referrer', 'no-referrer-when-downgrade', 'origin', 'origin-when-cross-origin', 'strict-origin', 'strict-origin-when-cross-origin', 'unsafe-url', received do-whatever`, + (e as Error).message, + `Expected\n\`\`\`\n${(e as Error).message}\n\`\`\` to match the expected error` + ); + } + }); + + test('We error meaningfully for invalid properties', async function (assert) { + const manager = new RequestManager(); + + try { + // @ts-expect-error + await manager.request({ url: '/wat', random: 'field' }); + assert.ok(false, 'we should error when the handler returns undefined'); + } catch (e: unknown) { + assert.true(e instanceof Error, 'We throw an error'); + assert.strictEqual( + `Invalid Request passed to \`RequestManager.request()\`. + +The following issues were found: + +\tInvalidKey: 'random'`, + (e as Error).message, + `Expected\n\`\`\`\n${(e as Error).message}\n\`\`\` to match the expected error` + ); + } + }); +}); diff --git a/tests/request/tests/integration/graceful-dev-handler-errors-test.ts b/tests/request/tests/integration/graceful-dev-handler-errors-test.ts new file mode 100644 index 00000000000..c5cc0b27fad --- /dev/null +++ b/tests/request/tests/integration/graceful-dev-handler-errors-test.ts @@ -0,0 +1,164 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ + +import { module, test } from 'qunit'; + +import { RequestManager } from '@ember-data/request'; +import type { Context } from '@ember-data/request/-private/context'; +import type { Handler, NextFn } from '@ember-data/request/-private/types'; + +module('RequestManager | Graceful Handler Errors', function () { + test('We error meaningfully for empty requests', async function (assert) { + const manager = new RequestManager(); + let called = false; + let nextArg: unknown = undefined; + const handler: Handler = { + request(context: Context, next: NextFn) { + called = true; + // @ts-expect-error + return nextArg ? next(nextArg) : next(); + }, + }; + manager.use([handler, handler]); + + try { + nextArg = undefined; + called = false; + await manager.request({ url: '/wat' }); + assert.ok(false, 'we should error when the request is missing'); + } catch (e: unknown) { + assert.true(called, 'we invoked the handler'); + assert.true(e instanceof Error, 'We throw an error when the request is missing'); + assert.strictEqual( + (e as Error).message, + 'Expected next() to be called with a request, but none was provided.', + `Expected: ${(e as Error).message} - to match the expected error` + ); + } + + try { + nextArg = []; + called = false; + await manager.request({ url: '/wat' }); + assert.ok(false, 'we should error when the request is not an object'); + } catch (e: unknown) { + assert.true(called, 'we invoked the handler'); + assert.true(e instanceof Error, 'We throw an error when the request is not an object'); + assert.strictEqual( + (e as Error).message, + 'The `request` passed to `next()` should be an object, received `array`', + `Expected: ${(e as Error).message} - to match the expected error` + ); + } + + try { + nextArg = {}; + called = false; + await manager.request({ url: '/wat' }); + assert.ok(false, 'we should error when the request has no keys'); + } catch (e: unknown) { + assert.true(called, 'we invoked the handler'); + assert.true(e instanceof Error, 'We throw an error when the request has no keys'); + assert.strictEqual( + (e as Error).message, + 'The `request` passed to `next()` was empty (`{}`). Requests need at least one valid key.', + `Expected: ${(e as Error).message} - to match the expected error` + ); + } + }); + + test('We error meaningfully for misshapen requests', async function (assert) { + const manager = new RequestManager(); + let called = false; + let nextArg: unknown = undefined; + const handler: Handler = { + request(context: Context, next: NextFn) { + called = true; + // @ts-expect-error + return nextArg ? next(nextArg) : next(); + }, + }; + manager.use([handler, handler]); + + try { + nextArg = { + url: true, + data: new Set(), + options: [], + cache: 'bogus', + credentials: 'never', + destination: 'space', + headers: new Map(), + integrity: false, + keepalive: 'yes', + method: 'get', + mode: 'find-out', + redirect: 'of course', + referrer: null, + referrerPolicy: 'do-whatever', + }; + await manager.request({ url: '/wat' }); + assert.ok(false, 'we should error when the handler returns undefined'); + } catch (e: unknown) { + assert.true(called, 'we invoked the handler'); + assert.true(e instanceof Error, 'We throw an error'); + assert.strictEqual( + `Invalid Request passed to \`next()\`. + +The following issues were found: + +\tInvalidValue: key url should be a non-empty string, received boolean +\tInvalidValue: key options should be an object +\tInvalidValue: key cache should be a one of 'default', 'force-cache', 'no-cache', 'no-store', 'only-if-cached', 'reload', received bogus +\tInvalidValue: key credentials should be a one of 'include', 'omit', 'same-origin', received never +\tInvalidValue: key destination should be a one of '', 'object', 'audio', 'audioworklet', 'document', 'embed', 'font', 'frame', 'iframe', 'image', 'manifest', 'paintworklet', 'report', 'script', 'sharedworker', 'style', 'track', 'video', 'worker', 'xslt', received space +\tInvalidValue: key headers should be an instance of Headers, received map +\tInvalidValue: key integrity should be a non-empty string, received boolean +\tInvalidValue: key keepalive should be a boolean, received string +\tInvalidValue: key method should be a one of 'GET', 'PUT', 'PATCH', 'DELETE', 'POST', 'OPTIONS', received get +\tInvalidValue: key mode should be a one of 'same-origin', 'cors', 'navigate', 'no-cors', received find-out +\tInvalidValue: key redirect should be a one of 'error', 'follow', 'manual', received of course +\tInvalidValue: key referrer should be a non-empty string, received object +\tInvalidValue: key referrerPolicy should be a one of '', 'same-origin', 'no-referrer', 'no-referrer-when-downgrade', 'origin', 'origin-when-cross-origin', 'strict-origin', 'strict-origin-when-cross-origin', 'unsafe-url', received do-whatever`, + (e as Error).message, + `Expected\n\`\`\`\n${(e as Error).message}\n\`\`\` to match the expected error` + ); + } + }); + + test('We error meaningfully for invalid properties', async function (assert) { + const manager = new RequestManager(); + let called = false; + let nextArg: unknown = undefined; + const handler: Handler = { + request(context: Context, next: NextFn) { + called = true; + // @ts-expect-error + return nextArg ? next(nextArg) : next(); + }, + }; + manager.use([handler, handler]); + + try { + nextArg = { + url: '/wat', + random: 'field', + }; + await manager.request({ url: '/wat' }); + assert.ok(false, 'we should error when the handler returns undefined'); + } catch (e: unknown) { + assert.true(called, 'we invoked the handler'); + assert.true(e instanceof Error, 'We throw an error'); + assert.strictEqual( + `Invalid Request passed to \`next()\`. + +The following issues were found: + +\tInvalidKey: 'random'`, + (e as Error).message, + `Expected\n\`\`\`\n${(e as Error).message}\n\`\`\` to match the expected error` + ); + } + }); +}); diff --git a/tests/request/tests/integration/immutability-test.ts b/tests/request/tests/integration/immutability-test.ts new file mode 100644 index 00000000000..7211aed9b90 --- /dev/null +++ b/tests/request/tests/integration/immutability-test.ts @@ -0,0 +1,68 @@ +import { module, test } from 'qunit'; + +import { RequestManager } from '@ember-data/request'; +import type { Context } from '@ember-data/request/-private/context'; +import type { Handler, NextFn } from '@ember-data/request/-private/types'; + +module('RequestManager | Immutability', function () { + test('RequestInfo passed to a handler is Immutable', async function (assert) { + const manager = new RequestManager(); + const handler: Handler = { + request(context: Context, next: NextFn) { + // @ts-expect-error + context.request.integrity = 'some val'; + return Promise.resolve('hello' as T); + }, + }; + manager.use([handler]); + + try { + await manager.request({ url: '/foo', headers: new Headers([['foo', 'bar']]) }); + assert.ok(false, 'we should have erred'); + } catch (e) { + assert.strictEqual( + (e as Error).message, + 'Cannot add property integrity, object is not extensible', + `expected ${(e as Error).message} to match the expected error` + ); + } + }); + test('Headers in RequestInfo passed to a handler are Immutable', async function (assert) { + const manager = new RequestManager(); + const handler: Handler = { + request(context: Context, next: NextFn) { + context.request.headers!.append('house', 'home'); + return Promise.resolve('hello' as T); + }, + }; + manager.use([handler]); + + try { + await manager.request({ url: '/foo', headers: new Headers([['foo', 'bar']]) }); + assert.ok(false, 'we should have erred'); + } catch (e) { + assert.strictEqual( + (e as Error).message, + 'Cannot Mutate Immutatable Headers, use headers.clone to get a copy', + `expected ${(e as Error).message} to match the expected error` + ); + } + }); + test('Headers in RequestInfo passed to a handler may be edited after cloning', async function (assert) { + const manager = new RequestManager(); + const handler: Handler = { + request(context: Context, next: NextFn) { + const headers = context.request.headers!.clone(); + headers.append('house', 'home'); + return Promise.resolve([...headers.entries()] as T); + }, + }; + manager.use([handler]); + + const { data: headers } = await manager.request({ url: '/foo', headers: new Headers([['foo', 'bar']]) }); + assert.deepEqual(headers, [ + ['foo', 'bar'], + ['house', 'home'], + ]); + }); +}); diff --git a/tests/request/tests/integration/request-manager-test.ts b/tests/request/tests/integration/request-manager-test.ts new file mode 100644 index 00000000000..4e3b7d7c25f --- /dev/null +++ b/tests/request/tests/integration/request-manager-test.ts @@ -0,0 +1,7 @@ +import { module, test } from 'qunit'; + +module('RequestManager', function () { + test('Test Suit Configured', function (assert) { + assert.ok('We are configured'); + }); +}); diff --git a/tests/request/tests/integration/response-currying-test.ts b/tests/request/tests/integration/response-currying-test.ts new file mode 100644 index 00000000000..d994c54349e --- /dev/null +++ b/tests/request/tests/integration/response-currying-test.ts @@ -0,0 +1,275 @@ +import { module, test } from 'qunit'; + +import { RequestManager } from '@ember-data/request'; +import type { Context } from '@ember-data/request/-private/context'; +import type { Handler, NextFn } from '@ember-data/request/-private/types'; + +module('RequestManager | Response Currying', function () { + test('We curry response when setResponse is not called', async function (assert) { + const manager = new RequestManager(); + const handler1: Handler = { + async request(context: Context, next: NextFn) { + const response = await next(context.request); + return response.data; + }, + }; + const handler2: Handler = { + async request(context: Context, next: NextFn) { + const response = await fetch(context.request.url!, context.request); + context.setResponse(response); + return response.json(); + }, + }; + manager.use([handler1, handler2]); + + const doc = await manager.request({ url: '../assets/demo-fetch.json' }); + const serialized = JSON.parse(JSON.stringify(doc.response)) as unknown; + // @ts-expect-error + serialized.headers = (serialized.headers as [string, string][]).filter((v) => { + // don't test headers that change every time + return !['content-length', 'date', 'etag', 'last-modified'].includes(v[0]); + }); + // @ts-expect-error port is unstable in CI + delete serialized.url; + + assert.deepEqual( + serialized, + { + ok: true, + redirected: false, + headers: [ + ['accept-ranges', 'bytes'], + ['cache-control', 'public, max-age=0'], + ['content-type', 'application/json; charset=UTF-8'], + // ['date', 'Wed, 23 Nov 2022 05:17:11 GMT'], + // ['etag', 'W/"39-1849db13af9"'], + // ['last-modified', 'Tue, 22 Nov 2022 04:55:48 GMT'], + ['vary', 'Accept-Encoding'], + ['x-powered-by', 'Express'], + ], + status: 200, + statusText: 'OK', + type: 'basic', + }, + 'The response is processed correctly' + ); + }); + + test('We do not curry response when we call next multiple times', async function (assert) { + const manager = new RequestManager(); + const handler1: Handler = { + async request(context: Context, next: NextFn): Promise { + await next(context.request); + await next(context.request); + return (await next(context.request)).data; + }, + }; + const handler2: Handler = { + async request(context: Context, next: NextFn) { + const response = await fetch(context.request.url!, context.request); + context.setResponse(response); + return response.json(); + }, + }; + manager.use([handler1, handler2]); + + const doc = await manager.request({ url: '../assets/demo-fetch.json' }); + + assert.strictEqual(doc.response, null, 'The response is processed correctly'); + }); + + test('We curry when we return directly', async function (assert) { + const manager = new RequestManager(); + const handler1: Handler = { + async request(context: Context, next: NextFn): Promise { + return next(context.request) as unknown as Promise; + }, + }; + const handler2: Handler = { + async request(context: Context, next: NextFn) { + const response = await fetch(context.request.url!, context.request); + context.setResponse(response); + return response.json(); + }, + }; + manager.use([handler1, handler2]); + + const doc = await manager.request({ url: '../assets/demo-fetch.json' }); + const serialized = JSON.parse(JSON.stringify(doc.response)) as unknown; + // @ts-expect-error + serialized.headers = (serialized.headers as [string, string][]).filter((v) => { + // don't test headers that change every time + return !['content-length', 'date', 'etag', 'last-modified'].includes(v[0]); + }); + // @ts-expect-error port is unstable in CI + delete serialized.url; + + assert.deepEqual( + serialized, + { + ok: true, + redirected: false, + headers: [ + ['accept-ranges', 'bytes'], + ['cache-control', 'public, max-age=0'], + ['content-type', 'application/json; charset=UTF-8'], + // ['date', 'Wed, 23 Nov 2022 05:17:11 GMT'], + // ['etag', 'W/"39-1849db13af9"'], + // ['last-modified', 'Tue, 22 Nov 2022 04:55:48 GMT'], + ['vary', 'Accept-Encoding'], + ['x-powered-by', 'Express'], + ], + status: 200, + statusText: 'OK', + type: 'basic', + }, + 'The response is processed correctly' + ); + }); + + test('We can intercept Response', async function (assert) { + const manager = new RequestManager(); + const handler1: Handler = { + async request(context: Context, next: NextFn): Promise { + const doc = await next(context.request); + + const response = Object.assign({}, doc.response, { ok: false }); + context.setResponse(response); + + return doc.data; + }, + }; + const handler2: Handler = { + async request(context: Context, next: NextFn) { + const response = await fetch(context.request.url!, context.request); + context.setResponse(response); + return response.json(); + }, + }; + manager.use([handler1, handler2]); + + const doc = await manager.request({ url: '../assets/demo-fetch.json' }); + const serialized = JSON.parse(JSON.stringify(doc.response)) as unknown; + // @ts-expect-error + serialized.headers = (serialized.headers as [string, string][]).filter((v) => { + // don't test headers that change every time + return !['content-length', 'date', 'etag', 'last-modified'].includes(v[0]); + }); + // @ts-expect-error port is unstable in CI + delete serialized.url; + + assert.deepEqual( + serialized, + { + ok: false, + redirected: false, + headers: [ + ['accept-ranges', 'bytes'], + ['cache-control', 'public, max-age=0'], + ['content-type', 'application/json; charset=UTF-8'], + // ['date', 'Wed, 23 Nov 2022 05:17:11 GMT'], + // ['etag', 'W/"39-1849db13af9"'], + // ['last-modified', 'Tue, 22 Nov 2022 04:55:48 GMT'], + ['vary', 'Accept-Encoding'], + ['x-powered-by', 'Express'], + ], + status: 200, + statusText: 'OK', + type: 'basic', + }, + 'The response is processed correctly' + ); + }); + + test("We can can't mutate Response", async function (assert) { + assert.expect(3); + const manager = new RequestManager(); + const handler1: Handler = { + async request(context: Context, next: NextFn): Promise { + const doc = await next(context.request); + + try { + // @ts-expect-error + doc.response!.ok = false; + assert.ok(false, 'we should be immutable'); + } catch (e) { + assert.ok(true, 'we are immutable'); + } + + try { + doc.response!.headers.append('foo', 'bar'); + assert.ok(false, 'we should be immutable'); + } catch (e) { + assert.ok(true, 'we are immutable'); + } + + return doc.data; + }, + }; + const handler2: Handler = { + async request(context: Context, next: NextFn) { + const response = await fetch(context.request.url!, context.request); + context.setResponse(response); + return response.json(); + }, + }; + manager.use([handler1, handler2]); + + const doc = await manager.request({ url: '../assets/demo-fetch.json' }); + const serialized = JSON.parse(JSON.stringify(doc.response)) as unknown; + // @ts-expect-error + serialized.headers = (serialized.headers as [string, string][]).filter((v) => { + // don't test headers that change every time + return !['content-length', 'date', 'etag', 'last-modified'].includes(v[0]); + }); + // @ts-expect-error port is unstable in CI + delete serialized.url; + + assert.deepEqual( + serialized, + { + ok: true, + redirected: false, + headers: [ + ['accept-ranges', 'bytes'], + ['cache-control', 'public, max-age=0'], + ['content-type', 'application/json; charset=UTF-8'], + // ['date', 'Wed, 23 Nov 2022 05:17:11 GMT'], + // ['etag', 'W/"39-1849db13af9"'], + // ['last-modified', 'Tue, 22 Nov 2022 04:55:48 GMT'], + ['vary', 'Accept-Encoding'], + ['x-powered-by', 'Express'], + ], + status: 200, + statusText: 'OK', + type: 'basic', + }, + 'The response is processed correctly' + ); + }); + + test('We can set response to null', async function (assert) { + const manager = new RequestManager(); + const handler1: Handler = { + async request(context: Context, next: NextFn): Promise { + const doc = await next(context.request); + + context.setResponse(null); + + return doc.data; + }, + }; + const handler2: Handler = { + async request(context: Context, next: NextFn) { + const response = await fetch(context.request.url!, context.request); + context.setResponse(response); + return response.json(); + }, + }; + manager.use([handler1, handler2]); + + const doc = await manager.request({ url: '../assets/demo-fetch.json' }); + + assert.strictEqual(doc.response, null, 'The response is processed correctly'); + }); +}); diff --git a/tests/request/tests/integration/response-test.ts b/tests/request/tests/integration/response-test.ts new file mode 100644 index 00000000000..dbe8af9a7ba --- /dev/null +++ b/tests/request/tests/integration/response-test.ts @@ -0,0 +1,51 @@ +import { module, test } from 'qunit'; + +import { RequestManager } from '@ember-data/request'; +import type { Context } from '@ember-data/request/-private/context'; +import type { Handler, NextFn } from '@ember-data/request/-private/types'; + +module('RequestManager | Response', function () { + test('Handlers may set response via Response', async function (assert) { + const manager = new RequestManager(); + const handler: Handler = { + async request(context: Context, next: NextFn) { + const response = await fetch(context.request.url!, context.request); + context.setResponse(response); + return response.json(); + }, + }; + manager.use([handler]); + + const doc = await manager.request({ url: '../assets/demo-fetch.json' }); + const serialized = JSON.parse(JSON.stringify(doc.response)) as unknown; + // @ts-expect-error + serialized.headers = (serialized.headers as [string, string][]).filter((v) => { + // don't test headers that change every time + return !['content-length', 'date', 'etag', 'last-modified'].includes(v[0]); + }); + // @ts-expect-error port is unstable in CI + delete serialized.url; + + assert.deepEqual( + serialized, + { + ok: true, + redirected: false, + headers: [ + ['accept-ranges', 'bytes'], + ['cache-control', 'public, max-age=0'], + ['content-type', 'application/json; charset=UTF-8'], + // ['date', 'Wed, 23 Nov 2022 05:17:11 GMT'], + // ['etag', 'W/"39-1849db13af9"'], + // ['last-modified', 'Tue, 22 Nov 2022 04:55:48 GMT'], + ['vary', 'Accept-Encoding'], + ['x-powered-by', 'Express'], + ], + status: 200, + statusText: 'OK', + type: 'basic', + }, + 'The response is processed correctly' + ); + }); +}); diff --git a/tests/request/tests/integration/service-test.ts b/tests/request/tests/integration/service-test.ts new file mode 100644 index 00000000000..2c62844dc21 --- /dev/null +++ b/tests/request/tests/integration/service-test.ts @@ -0,0 +1,36 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +import { getOwner } from '@ember/application'; +import Service, { inject as service } from '@ember/service'; +import { TestContext } from '@ember/test-helpers'; + +import { module, test } from 'qunit'; + +import { setupTest } from 'ember-qunit'; + +import { RequestManager } from '@ember-data/request'; + +module('RequestManager | Ember Service Setup', function (hooks: NestedHooks) { + setupTest(hooks); + + test('We can register RequestManager as a service', function (this: TestContext, assert: Assert) { + this.owner.register('service:request', RequestManager); + const manager = this.owner.lookup('service:request'); + assert.ok(manager instanceof RequestManager, 'We instantiated'); + }); + + test('We can use injections when registering the RequestManager as a service', function (this: TestContext, assert: Assert) { + class CustomManager extends RequestManager { + @service cache; + } + this.owner.register('service:request', CustomManager); + class Cache extends Service {} + this.owner.register('service:cache', Cache); + const manager = this.owner.lookup('service:request'); + assert.ok(manager instanceof RequestManager, 'We instantiated'); + assert.ok(manager instanceof CustomManager, 'We instantiated'); + assert.ok(manager.cache instanceof Cache, 'We can utilize injections'); + assert.strictEqual(getOwner(manager), this.owner, 'The manager correctly sets owner'); + }); +}); diff --git a/tests/request/tests/integration/setup-test.ts b/tests/request/tests/integration/setup-test.ts new file mode 100644 index 00000000000..dfa69c7fb76 --- /dev/null +++ b/tests/request/tests/integration/setup-test.ts @@ -0,0 +1,91 @@ +import { module, test } from 'qunit'; + +import { RequestManager } from '@ember-data/request'; +import type { Context as HandlerRequestContext } from '@ember-data/request/-private/context'; +import type { NextFn } from '@ember-data/request/-private/types'; + +module('RequestManager | Basic Setup', function () { + test('We can call new RequestManager() with no args', function (assert) { + const manager = new RequestManager(); + assert.ok(manager instanceof RequestManager, 'We instantiated'); + }); + + test('We can call RequestManager.create() with no args', function (assert) { + const manager = RequestManager.create(); + assert.ok(manager instanceof RequestManager, 'We instantiated'); + }); + + test('We can register a handler with `.use()`', async function (assert) { + const manager = new RequestManager(); + let calls = 0; + manager.use([ + { + request(req: HandlerRequestContext, next: NextFn) { + calls++; + return Promise.resolve('success!' as T); + }, + }, + ]); + const req = { + url: '/foos', + }; + const result = await manager.request(req); + assert.strictEqual(calls, 1, 'we called our handler'); + assert.strictEqual(JSON.stringify(result.request), JSON.stringify(req)); + assert.strictEqual(result.data, 'success!', 'we returned the expected result'); + }); + + test('We can register multiple handlers with `.use()`', async function (assert) { + const manager = new RequestManager(); + let calls = 0; + let callsB = 0; + manager.use([ + { + async request(req: HandlerRequestContext, next: NextFn) { + calls++; + const outcome = await next(req.request); + return outcome.data; + }, + }, + { + request(req: HandlerRequestContext, next: NextFn) { + callsB++; + return Promise.resolve('success!' as T); + }, + }, + ]); + const req = { + url: '/foos', + }; + const result = await manager.request(req); + assert.strictEqual(calls, 1, 'we called our handler'); + assert.strictEqual(callsB, 1, 'we called our next handler'); + assert.strictEqual(JSON.stringify(result.request), JSON.stringify(req)); + assert.strictEqual(result.data, 'success!', 'we returned the expected result'); + }); + + test('We can register the same handler more than once with `.use()`', async function (assert) { + const manager = new RequestManager(); + let calls = 0; + + const handler = { + async request(req: HandlerRequestContext, next: NextFn) { + calls++; + if (calls === 2) { + return Promise.resolve('success!' as T); + } + const outcome = await next(req.request); + return outcome.data; + }, + }; + + manager.use([handler, handler]); + const req = { + url: '/foos', + }; + const result = await manager.request(req); + assert.strictEqual(calls, 2, 'we called our handler'); + assert.strictEqual(JSON.stringify(result.request), JSON.stringify(req)); + assert.strictEqual(result.data, 'success!', 'we returned the expected result'); + }); +}); diff --git a/tests/request/tests/integration/streams-test.ts b/tests/request/tests/integration/streams-test.ts new file mode 100644 index 00000000000..440f1de3333 --- /dev/null +++ b/tests/request/tests/integration/streams-test.ts @@ -0,0 +1,294 @@ +import { module, test } from 'qunit'; + +import { RequestManager } from '@ember-data/request'; +import type { Context } from '@ember-data/request/-private/context'; +import type { Future, Handler, NextFn } from '@ember-data/request/-private/types'; + +module('RequestManager | Streams', function () { + test('We can read the stream returned from a handler', async function (assert) { + assert.expect(2); + const manager = new RequestManager(); + const handler: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + const result = await fetch(context.request.url!, context.request); + + if (result.body) { + context.setStream(result.clone().body!); + } + + return result.json() as T; + }, + }; + manager.use([handler]); + + const future = manager.request({ url: '../assets/demo-fetch.json' }); + const stream = await future.getStream(); + + assert.true(stream instanceof ReadableStream, 'we receive the stream'); + const result = await future; + assert.deepEqual( + result.data, + { + data: { + type: 'example', + id: '1', + }, + }, + 'Final response is correct' + ); + }); + + test('We can proxy the stream from a parent handler', async function (assert) { + assert.expect(2); + const manager = new RequestManager(); + const handler1: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + const future = next(context.request); + + context.setStream(future.getStream()); + + return (await future).data; + }, + }; + const handler2: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + const result = await fetch(context.request.url!, context.request); + + if (result.body) { + context.setStream(result.clone().body!); + } + + return result.json() as T; + }, + }; + manager.use([handler1, handler2]); + + const future = manager.request({ url: '../assets/demo-fetch.json' }); + const stream = await future.getStream(); + + assert.true(stream instanceof ReadableStream, 'we receive the stream'); + const result = await future; + assert.deepEqual( + result.data, + { + data: { + type: 'example', + id: '1', + }, + }, + 'Final response is correct' + ); + }); + + test('We can interrupt the stream from a parent handler', async function (assert) { + assert.expect(3); + const manager = new RequestManager(); + const handler1: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + const future = next(context.request); + const stream = await future.getStream(); + assert.true(stream instanceof ReadableStream, 'we receive the stream'); + // we don't set stream + + return (await future).data; + }, + }; + const handler2: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + const result = await fetch(context.request.url!, context.request); + + if (result.body) { + context.setStream(result.clone().body!); + } + + return result.json() as T; + }, + }; + manager.use([handler1, handler2]); + + const future = manager.request({ url: '../assets/demo-fetch.json' }); + const stream = await future.getStream(); + + assert.strictEqual(stream, null, 'we receive the null as the stream'); + const result = await future; + assert.deepEqual( + result.data, + { + data: { + type: 'example', + id: '1', + }, + }, + 'Final response is correct' + ); + }); + + test('We can curry the stream from a parent handler by not accessing it', async function (assert) { + assert.expect(2); + const manager = new RequestManager(); + const handler1: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + const future = next(context.request); + return (await future).data; + }, + }; + const handler2: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + const result = await fetch(context.request.url!, context.request); + + if (result.body) { + context.setStream(result.clone().body!); + } + + return result.json() as T; + }, + }; + manager.use([handler1, handler2]); + + const future = manager.request({ url: '../assets/demo-fetch.json' }); + const stream = await future.getStream(); + + assert.true(stream instanceof ReadableStream, 'we receive the stream'); + const result = await future; + assert.deepEqual( + result.data, + { + data: { + type: 'example', + id: '1', + }, + }, + 'Final response is correct' + ); + }); + + test('We curry the stream when returning the future directly', async function (assert) { + assert.expect(2); + const manager = new RequestManager(); + const handler1: Handler = { + request(context: Context, next: NextFn): Promise | Future { + return next(context.request); + }, + }; + const handler2: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + const result = await fetch(context.request.url!, context.request); + + if (result.body) { + context.setStream(result.clone().body!); + } + + return result.json() as T; + }, + }; + manager.use([handler1, handler2]); + + const future = manager.request({ url: '../assets/demo-fetch.json' }); + const stream = await future.getStream(); + + assert.true(stream instanceof ReadableStream, 'we receive the stream'); + const result = await future; + assert.deepEqual( + result.data, + { + data: { + type: 'example', + id: '1', + }, + }, + 'Final response is correct' + ); + }); + + test('We do not curry the stream when calling next more than once', async function (assert) { + assert.expect(2); + const manager = new RequestManager(); + const handler1: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + const a = await next(context.request); + const b = await next(context.request); + return a.data || b.data; + }, + }; + const handler2: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + const result = await fetch(context.request.url!, context.request); + + if (result.body) { + context.setStream(result.clone().body!); + } + + return result.json() as T; + }, + }; + manager.use([handler1, handler2]); + + const future = manager.request({ url: '../assets/demo-fetch.json' }); + const stream = await future.getStream(); + + assert.strictEqual(stream, null, 'we do not receive the stream'); + const result = await future; + assert.deepEqual( + result.data, + { + data: { + type: 'example', + id: '1', + }, + }, + 'Final response is correct' + ); + }); + + test('We curry the stream when calling next more than once if a future is returned', async function (assert) { + assert.expect(2); + const manager = new RequestManager(); + const handler1: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + await next(context.request); + await next(context.request); + return next(context.request); + }, + }; + const handler2: Handler = { + // @ts-expect-error + async request(context: Context, next: NextFn): Promise | Future { + const result = await fetch(context.request.url!, context.request); + + if (result.body) { + context.setStream(result.clone().body!); + } + + return result.json() as T; + }, + }; + manager.use([handler1, handler2]); + + const future = manager.request({ url: '../assets/demo-fetch.json' }); + const stream = await future.getStream(); + + assert.true(stream instanceof ReadableStream, 'we receive the stream'); + const result = await future; + assert.deepEqual( + result.data, + { + data: { + type: 'example', + id: '1', + }, + }, + 'Final response is correct' + ); + }); +}); diff --git a/tests/request/tests/test-helper.js b/tests/request/tests/test-helper.js new file mode 100644 index 00000000000..147cf2a3d4d --- /dev/null +++ b/tests/request/tests/test-helper.js @@ -0,0 +1,54 @@ +import { setApplication } from '@ember/test-helpers'; + +import * as QUnit from 'qunit'; +import { setup } from 'qunit-dom'; +import RSVP from 'rsvp'; + +import { start } from 'ember-qunit'; + +import assertAllDeprecations from '@ember-data/unpublished-test-infra/test-support/assert-all-deprecations'; +import configureAsserts from '@ember-data/unpublished-test-infra/test-support/qunit-asserts'; +import customQUnitAdapter from '@ember-data/unpublished-test-infra/test-support/testem/custom-qunit-adapter'; + +import Application from '../app'; +import config from '../config/environment'; + +if (window.Promise === undefined) { + window.Promise = RSVP.Promise; +} + +// Handle testing feature flags +if (QUnit.urlParams.enableoptionalfeatures) { + window.EmberDataENV = { ENABLE_OPTIONAL_FEATURES: true }; +} + +setup(QUnit.assert); + +configureAsserts(); + +setApplication(Application.create(config.APP)); + +assertAllDeprecations(); + +if (window.Testem) { + window.Testem.useCustomAdapter(customQUnitAdapter); +} + +QUnit.begin(function () { + RSVP.configure('onerror', (reason) => { + // only print error messages if they're exceptions; + // otherwise, let a future turn of the event loop + // handle the error. + // TODO kill this off + if (reason && reason instanceof Error) { + throw reason; + } + }); +}); + +QUnit.config.testTimeout = 2000; +QUnit.config.urlConfig.push({ + id: 'enableoptionalfeatures', + label: 'Enable Opt Features', +}); +start({ setupTestIsolationValidation: true });