Skip to content

Commit 46728d2

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. Riding along with the regeneration is specification drift, and one part of it breaks callers. Four operations declared their body as `Record<string, any>` — the generator's fallback for a request body that is not an object — and now declare what the endpoint reads: `addWatcher` and `setPreference` take a `string`, `updateEntityPropertiesValue` an `EntityPropertyDetails[]`, and Service Management's `attachTemporaryFile` a `MultipartFile[]`. None of the four could be called correctly through the old declaration; the live suite carried two helpers whose only job was to cast a string past it, and both are deleted here. That cast is the break. `updateEntityPropertiesValue` and `attachTemporaryFile` had no coverage at all, and a live run cannot give them much: one is addressed with a Connect app's JWT, the other needs an agent licence this tenant does not hold. Both gain a live test pinning the typed refusal, and all four gain unit coverage asserting what leaves the client — the neighbouring `updateIssueFields` wraps its body under a key, so a bare array is a distinction worth holding. Four generated doc comments are restored rather than regressed. The generator's HTML stripper could not tell a tag from a placeholder — `<project ID or key>` reads as an element named `project` — and turned a format string into "The format is `,,`" while emptying the API token out of three curl examples. Fixed upstream in apis-code-gen; regenerating with the fix changes exactly these four files, all of them restorations to what master already says.
1 parent dbf6d78 commit 46728d2

165 files changed

Lines changed: 6031 additions & 1760 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: 65 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:
@@ -19,6 +62,28 @@
1962

2063
`validation.notExported` makes the reference a check on the public surface besides: a type built out of something the package does not export fails the documentation build. Five symbols were in exactly that position and are exported now — `authBasicSchema`, `authBearerSchema`, `CommonClientConfig`, `ParsedClientConfig` and `ErrorKind`.
2164

65+
### Types
66+
67+
* **Four request bodies are typed as the shape the endpoint reads, where they were `Record<string, any>`.** Each was generated from a request body the specification declares as something other than an object, which the generator had no reading for and degraded to an object of arbitrary keys. None of the four could be called correctly through its own declaration.
68+
69+
| Operation | Body was | Body is |
70+
| --- | --- | --- |
71+
| `issueWatchers.addWatcher` | `Record<string, any>` | `string` |
72+
| `myself.setPreference` | `Record<string, any>` | `string` |
73+
| `appMigration.updateEntityPropertiesValue` | `Record<string, any>` | `EntityPropertyDetails[]` |
74+
| `servicedesk.attachTemporaryFile` (Service Desk) | `Record<string, any>` | `MultipartFile[]` |
75+
76+
```ts
77+
await jira.issueWatchers.addWatcher({ issueIdOrKey: 'PROJ-1', body: '5b10ac8d82e05b22cc7d4ef5' });
78+
await jira.myself.setPreference({ key: 'user.notifications.mimetype', body: 'text' });
79+
```
80+
81+
The two that take a lone string were the sharper break. `addWatcher` wants an account id and `setPreference` a preference value, each sent as a JSON string — and an object was never a value either would accept, so the only way to call them was to cast a string past the declaration. The live suite did exactly that, with a helper whose whole purpose was to launder a `string` into a `Record<string, unknown>`. Those casts are what stops compiling now, and deleting each one is the fix.
82+
83+
The two that take an array break more quietly: an array satisfies `Record<string, any>`, so a caller already passing the right thing is untouched and needs no change. A caller passing a single object, which the old declaration invited and the endpoint refused, is the one the compiler now stops.
84+
85+
`tests/unit/nonObjectBodies.test.ts` pins all four against the wire. It is unit rather than live because two of them cannot be reached: `updateEntityPropertiesValue` is addressed with a Connect app's JWT, and `attachTemporaryFile` needs an agent licence — so what a live run can show is the typed refusal, and what it cannot show is that the body left the client as a bare string or a top-level array rather than wrapped in a key.
86+
2287
### General
2388

2489
* **The minimum TypeScript is declared, and measured.** `>=5.7`, as an optional peer dependency, in the README and here. It was never written down before, and the honest number is higher than anyone would have guessed: the declarations name `ArrayBufferView`, which became generic in 5.7, so a 5.6 compiler reads them as an error unless `skipLibCheck` hides them from it. `check:consumers` now installs exactly that version and type-checks the packed tarball with `skipLibCheck` off, so the floor moves only when someone means to move it.

eslint.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export default defineConfig([
2020
},
2121
rules: {
2222
'no-empty': ['error', { allowEmptyCatch: true }],
23+
'no-nested-ternary': 'error',
2324
'@stylistic/comma-dangle': ['error', 'always-multiline'],
2425
'@stylistic/indent': ['error', 2],
2526
'@stylistic/lines-between-class-members': [

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)