Skip to content

Commit be3e6e0

Browse files
committed
feat: the transport takes a signal, a fetch of your own, and stops taking a refused credential for a result
Three requests older than 6.0, all of them the same shape: the client had no seam. `fetch` was reached as a global, a request could not be cancelled, and a response's headers were read for one thing and thrown away. None of it was visible from outside. Every call takes an `AbortSignal` as an optional last argument — an operation with no parameters takes it in their place — and the signal reaches `fetch` as well as cutting short a retry back-off that until now had no bound on total wall time. The abort reason is rethrown untouched rather than wrapped, so `error.name === 'AbortError'` and `error === signal.reason` both hold, and a `TimeoutError` from `AbortSignal.timeout()` stays one. Fixes #406. The `fetch` the client calls is yours to replace. It receives the URL and the `RequestInit` the client built, headers included, and returns a `Response` — logging, tracing, a corporate proxy, fixture recording. The OAuth 2.0 token and cloud-id calls go through it too, so a proxy covers the whole flow rather than working until the first refresh. Its type is `(url: string, init: RequestInit) => Promise<Response>` rather than `typeof globalThis.fetch`, so undici's fetch fits without a cast. Fixes #404. An expired API token is an error rather than an empty result, and this changes behaviour. Around a quarter of Jira's operations can be reached anonymously, and there a dead token does not fail the request: Jira answers as the anonymous user and reports the refusal only in `X-Seraph-LoginReason`. Measured against a live site, `GET /rest/api/3/project/search` with a dead token answers 200 and `{"total":0,"isLast":true,"values":[]}`. The client reads that header now and throws `AuthError` whatever the status, recording the status that actually arrived. Fixes #418. Two smaller ones ride along. A header set to `undefined` is left out rather than sent, and falls through to the client-wide value instead of erasing it. And two authentication strategies for a self-hosted instance arrive with the core rather than with the surface they serve — `src/core` is generated whole and `createClient` branches on the strategy, so separating them would mean hand-writing a core the generator does not produce.
1 parent d9bad39 commit be3e6e0

163 files changed

Lines changed: 5856 additions & 1758 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,49 @@
22

33
## 6.3.0
44

5+
### The transport
6+
7+
Three long-standing requests, all of them the same shape: the client had no seam. `fetch` was reached as a global, a request could not be cancelled, and a response's headers were read for one thing and thrown away. None of that was visible from outside, and all three issues predate 6.0.
8+
9+
* **Every call takes an `AbortSignal`.** Each method gains an optional argument after its parameters — an operation that takes no parameters takes it in their place — and the signal reaches `fetch`. It also cuts short a retry back-off, which until now had no upper bound on total wall time. Fixes [#406](https://github.com/MrRefactoring/jira.js/issues/406).
10+
11+
```ts
12+
await jira.issues.getIssue({ issueIdOrKey: 'PROJ-1' }, { signal: AbortSignal.timeout(5_000) });
13+
await jira.announcementBanner.getBanner({ signal });
14+
```
15+
16+
The abort reason is rethrown untouched rather than wrapped in a `NetworkError`: `error.name === 'AbortError'` is what the ecosystem branches on, a `TimeoutError` from `AbortSignal.timeout()` stays a `TimeoutError`, and a reason of your own comes back as the object you passed. Aborting one call never disturbs an OAuth 2.0 refresh in flight — that refresh is single-flighted and shared by every concurrent request on the client.
17+
18+
Nothing that compiled before stops compiling: the argument is optional and it is last.
19+
20+
* **The `fetch` the client calls is yours to replace.** `fetch` in the client configuration receives the URL and the `RequestInit` the client built, headers included, and returns a `Response`. That covers logging, tracing, a corporate proxy and fixture recording — what `middlewares` was used for before 6.0 removed axios, and what has had no replacement since. Fixes [#404](https://github.com/MrRefactoring/jira.js/issues/404).
21+
22+
```ts
23+
import { fetch as undiciFetch, ProxyAgent } from 'undici';
24+
25+
const dispatcher = new ProxyAgent(process.env.HTTPS_PROXY!);
26+
27+
const jira = createCloudClient({ host, auth, fetch: (url, init) => undiciFetch(url, { ...init, dispatcher }) });
28+
```
29+
30+
The OAuth 2.0 token and cloud-id calls go through it too, so a proxy covers the whole flow rather than working until the first refresh an hour later. The flip side is worth stating plainly: a wrapper that logs request bodies will see `client_secret` and `refresh_token` on the token call.
31+
32+
Its type is `(url: string, init: RequestInit) => Promise<Response>` rather than `typeof globalThis.fetch`, so undici's `fetch` — whose `RequestInit` carries `dispatcher` and lacks `duplex` — fits without a cast.
33+
34+
* **An expired API token is an error rather than an empty result. This changes behaviour.** Around a quarter of Jira's operations can be reached anonymously, and on those a dead or revoked token does not fail the request: Jira serves it as the anonymous user, returns a well-formed response containing whatever an anonymous visitor may see, and reports the refusal only in the `X-Seraph-LoginReason` header. Measured against a live site, `GET /rest/api/3/project/search` with a dead token answers `200` and `{"total":0,"isLast":true,"values":[]}`. Fixes [#418](https://github.com/MrRefactoring/jira.js/issues/418).
35+
36+
The client now reads that header and throws `AuthError` whenever it says the credentials were refused — whatever the status, because the header rides on `200`, `400` and `401` alike and the diagnosis is the same in each case. `error.status` records the status that actually arrived rather than a `401` that never happened; that is what the new `AuthErrorOptions.status` is for.
37+
38+
Two values count: `AUTHENTICATED_FAILED` and `AUTHENTICATION_DENIED`, both meaning the credentials were presented and refused. `AUTHORISATION_FAILED` does not — it means the user is who they claim and merely lacks a permission, which the status already carries and `ForbiddenError` already describes. A genuine permission denial was measured to send no such header at all. The check runs only when the client was given credentials, so a deliberately anonymous client is untouched, and `getAuthOn401` is offered the same single retry a plain `401` earns it.
39+
40+
If your code treated an empty result on a dead token as normal, it will now throw. It was reading anonymous data and calling it yours.
41+
42+
* **A header set to `undefined` is left out rather than sent.** `SendRequestOptions.headers` accepts `string | undefined`, and the transport strips the absent entries before the request is built — the same treatment `searchParams` and a JSON body have always had. An omitted header does not shadow one set in the client configuration either, so a per-request `undefined` falls through to the client-wide value instead of erasing it.
43+
44+
* **Two authentication strategies for a self-hosted instance.** `basic` accepts a local `username` and `password` beside the Cloud pair of `email` and `apiToken`, and `oauth2Server` is OAuth 2.0 against an instance's own authorization server — `generateServerAuthorizationUrl`, `exchangeServerAuthorizationCode`, `refreshServerOAuth2Token` and `createServerOAuth2Manager`, all of them addressed to the site's own domain with no cloud id and no gateway involved.
45+
46+
They arrive with the transport rather than with the surface they are for, because `src/core` is generated whole and `createClient` branches on the strategy — separating them would mean hand-writing a core the generator does not produce. No surface in this release answers to them yet.
47+
548
### Features
649

750
* **Request parameter types are importable again.** `CreateIssue`, `GetIssue` and the twelve hundred others appeared in no published declaration file: a surface entry point re-exports `api`, `models` and its factory, and never `parameters`. They now have a subpath of their own:

src/agile/api/backlog.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
import type { MoveIssuesToBacklog } from '../parameters/moveIssuesToBacklog';
22
import type { MoveIssuesToBacklogForBoard } from '../parameters/moveIssuesToBacklogForBoard';
3-
import type { Client, SendRequestOptions } from '#/core';
3+
import type { Client, RequestOptions, SendRequestOptions } from '#/core';
44

55
/**
66
* Move issues to the backlog. This operation is equivalent to remove future and active sprints from a given set of
77
* issues. At most 50 issues may be moved at once.
88
*/
9-
export async function moveIssuesToBacklog(client: Client, parameters: MoveIssuesToBacklog): Promise<void> {
9+
export async function moveIssuesToBacklog(
10+
client: Client,
11+
parameters: MoveIssuesToBacklog,
12+
options?: RequestOptions,
13+
): Promise<void> {
1014
const config: SendRequestOptions<void> = {
1115
url: '/rest/agile/1.0/backlog/issue',
1216
method: 'POST',
1317
body: {
1418
issues: parameters.issues,
1519
},
20+
signal: options?.signal,
1621
};
1722

1823
return await client.sendRequest(config);
@@ -26,6 +31,7 @@ export async function moveIssuesToBacklog(client: Client, parameters: MoveIssues
2631
export async function moveIssuesToBacklogForBoard(
2732
client: Client,
2833
parameters: MoveIssuesToBacklogForBoard,
34+
options?: RequestOptions,
2935
): Promise<void> {
3036
const config: SendRequestOptions<void> = {
3137
url: `/rest/agile/1.0/backlog/${parameters.boardId}/issue`,
@@ -36,6 +42,7 @@ export async function moveIssuesToBacklogForBoard(
3642
rankBeforeIssue: parameters.rankBeforeIssue,
3743
rankCustomFieldId: parameters.rankCustomFieldId,
3844
},
45+
signal: options?.signal,
3946
};
4047

4148
return await client.sendRequest(config);

0 commit comments

Comments
 (0)