@knpkv/jira-api-client 1.1.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * @packageDocumentation
5
5
  */
6
6
  export { JiraApiClient, type JiraApiClientContract, layer, make, type UploadAttachmentInput } from "./JiraApiClient.js";
7
- export { JiraApiConfig, type JiraApiConfigContract } from "./JiraApiConfig.js";
7
+ export { JiraApiConfig, type JiraApiConfigContract, type JiraApiCredential } from "./JiraApiConfig.js";
8
8
  export * as JiraApi from "./generated/JiraApi.js";
9
+ export { ownOptionalKey, type PrototypeKey } from "./OwnOptionalKey.js";
9
10
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,aAAa,EAAE,KAAK,qBAAqB,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAEvH,OAAO,EAAE,aAAa,EAAE,KAAK,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAE9E,OAAO,KAAK,OAAO,MAAM,wBAAwB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,aAAa,EAAE,KAAK,qBAAqB,EAAE,KAAK,EAAE,IAAI,EAAE,KAAK,qBAAqB,EAAE,MAAM,oBAAoB,CAAA;AAEvH,OAAO,EAAE,aAAa,EAAE,KAAK,qBAAqB,EAAE,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAEtG,OAAO,KAAK,OAAO,MAAM,wBAAwB,CAAA;AAEjD,OAAO,EAAE,cAAc,EAAE,KAAK,YAAY,EAAE,MAAM,qBAAqB,CAAA"}
package/dist/index.js CHANGED
@@ -6,4 +6,5 @@
6
6
  export { JiraApiClient, layer, make } from "./JiraApiClient.js";
7
7
  export { JiraApiConfig } from "./JiraApiConfig.js";
8
8
  export * as JiraApi from "./generated/JiraApi.js";
9
+ export { ownOptionalKey } from "./OwnOptionalKey.js";
9
10
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,aAAa,EAA8B,KAAK,EAAE,IAAI,EAA8B,MAAM,oBAAoB,CAAA;AAEvH,OAAO,EAAE,aAAa,EAA8B,MAAM,oBAAoB,CAAA;AAE9E,OAAO,KAAK,OAAO,MAAM,wBAAwB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,aAAa,EAA8B,KAAK,EAAE,IAAI,EAA8B,MAAM,oBAAoB,CAAA;AAEvH,OAAO,EAAE,aAAa,EAAsD,MAAM,oBAAoB,CAAA;AAEtG,OAAO,KAAK,OAAO,MAAM,wBAAwB,CAAA;AAEjD,OAAO,EAAE,cAAc,EAAqB,MAAM,qBAAqB,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knpkv/jira-api-client",
3
- "version": "1.1.1",
3
+ "version": "2.1.0",
4
4
  "description": "Effect-based Jira Cloud REST API v3 client",
5
5
  "license": "MIT",
6
6
  "author": "knpkv",
@@ -39,18 +39,18 @@
39
39
  "access": "public"
40
40
  },
41
41
  "peerDependencies": {
42
- "effect": "4.0.0-rc.109"
42
+ "effect": "4.0.0"
43
43
  },
44
44
  "devDependencies": {
45
- "@effect/openapi-generator": "4.0.0-rc.109",
46
- "@effect/platform-node": "4.0.0-rc.109",
47
- "@effect/vitest": "4.0.0-rc.109",
45
+ "@effect/openapi-generator": "4.0.0",
46
+ "@effect/platform-node": "4.0.0",
47
+ "@effect/vitest": "4.0.0",
48
48
  "@types/node": "latest",
49
- "effect": "4.0.0-rc.109",
50
- "prettier": "^3.9.6",
51
- "tsx": "^4.23.12",
49
+ "effect": "4.0.0",
50
+ "prettier": "^3.9.9",
51
+ "tsx": "^4.23.15",
52
52
  "typescript": "npm:@typescript/typescript6@^6.0.2",
53
- "vitest": "^4.1.10"
53
+ "vitest": "^5.0.3"
54
54
  },
55
55
  "keywords": [
56
56
  "effect",
@@ -67,7 +67,7 @@
67
67
  "test:watch": "vitest",
68
68
  "lint": "eslint src",
69
69
  "lint:fix": "eslint src --fix",
70
- "check": "tsc --noEmit && tsc -p scripts/tsconfig.json",
70
+ "check": "tsc --noEmit && tsc -p scripts/tsconfig.json && tsc -p test/tsconfig.json --noEmit",
71
71
  "regenerate": "tsx scripts/regenerate.ts",
72
72
  "regenerate:check": "tsx scripts/regenerate.ts --check"
73
73
  }
@@ -8,19 +8,19 @@
8
8
  import * as OpenApiGenerator from "@effect/openapi-generator/OpenApiGenerator"
9
9
  import * as OpenApiPatch from "@effect/openapi-generator/OpenApiPatch"
10
10
  import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node"
11
+ import * as Command from "effect/cli/Command"
12
+ import * as Flag from "effect/cli/Flag"
11
13
  import * as Console from "effect/Console"
12
14
  import * as Data from "effect/Data"
13
15
  import * as Effect from "effect/Effect"
14
16
  import * as FileSystem from "effect/FileSystem"
17
+ import type { OpenAPISpec } from "effect/http-api/OpenApi"
18
+ import * as HttpClient from "effect/http/HttpClient"
15
19
  import type * as JsonSchema from "effect/JsonSchema"
16
20
  import * as Layer from "effect/Layer"
17
21
  import * as Path from "effect/Path"
18
22
  import * as Predicate from "effect/Predicate"
19
23
  import * as Schema from "effect/Schema"
20
- import * as Command from "effect/unstable/cli/Command"
21
- import * as Flag from "effect/unstable/cli/Flag"
22
- import * as HttpClient from "effect/unstable/http/HttpClient"
23
- import type { OpenAPISpec } from "effect/unstable/httpapi/OpenApi"
24
24
  import { format as formatSource } from "prettier"
25
25
 
26
26
  const SPEC_URL = "https://dac-static.atlassian.com/cloud/jira/platform/swagger-v3.v3.json"
@@ -167,6 +167,17 @@ const normalizeResponses = (document: Schema.Json): Schema.Json => {
167
167
 
168
168
  const stripTrailingWhitespace = (source: string): string => source.replace(/[ \t]+$/gm, "")
169
169
 
170
+ /** Route optional keys named after `Object.prototype` members through `ownOptionalKey`; see `src/OwnOptionalKey.ts`. */
171
+ const ownPrototypeKeys = (source: string): string => {
172
+ const rewritten = source.replace(/"(toString)": Schema\.optionalKey\(/g, "\"$1\": ownOptionalKey(\"$1\", ")
173
+ return rewritten === source
174
+ ? source
175
+ : rewritten.replace(
176
+ /^(import \* as Schema from "effect\/Schema"\n)/m,
177
+ "$1import { ownOptionalKey } from \"../OwnOptionalKey.js\"\n"
178
+ )
179
+ }
180
+
170
181
  const generate = Effect.fn("Jira.regenerate.generate")(function*(scriptPaths: ScriptPaths, upstream: Schema.Json) {
171
182
  const fs = yield* FileSystem.FileSystem
172
183
  const patch = yield* OpenApiPatch.parsePatchInput(scriptPaths.patchFile).pipe(
@@ -195,17 +206,19 @@ const generate = Effect.fn("Jira.regenerate.generate")(function*(scriptPaths: Sc
195
206
  (warning) => Console.warn(`[${warning.code}] ${warning.method ?? ""} ${warning.path ?? ""}: ${warning.message}`),
196
207
  { discard: true }
197
208
  )
198
- yield* fs.writeFileString(scriptPaths.outputFile, stripTrailingWhitespace(generated)).pipe(
209
+ yield* fs.writeFileString(scriptPaths.outputFile, ownPrototypeKeys(stripTrailingWhitespace(generated))).pipe(
199
210
  Effect.mapError((cause) => new RegenerateError({ message: "Could not write generated Jira client", cause }))
200
211
  )
201
212
  })
202
213
 
203
- const check = Flag.boolean("check").pipe(
204
- Flag.withDescription("Exit non-zero when the committed upstream spec differs from Jira")
214
+ const check = Flag.Boolean("check").pipe(
215
+ Flag.withDescription("Exit non-zero when the committed upstream spec differs from Jira"),
216
+ Flag.withDefault(false)
205
217
  )
206
218
 
207
- const local = Flag.boolean("local").pipe(
208
- Flag.withDescription("Regenerate from the committed spec without contacting Jira")
219
+ const local = Flag.Boolean("local").pipe(
220
+ Flag.withDescription("Regenerate from the committed spec without contacting Jira"),
221
+ Flag.withDefault(false)
209
222
  )
210
223
 
211
224
  const root = Command.make("jira-api-regenerate", { check, local }).pipe(
@@ -9,17 +9,17 @@
9
9
  */
10
10
  import * as Context from "effect/Context"
11
11
  import * as Effect from "effect/Effect"
12
- import * as Encoding from "effect/Encoding"
12
+ import { Base64 } from "effect/encoding"
13
13
  import { flow } from "effect/Function"
14
+ import * as HttpClient from "effect/http/HttpClient"
15
+ import type * as HttpClientError from "effect/http/HttpClientError"
16
+ import * as HttpClientRequest from "effect/http/HttpClientRequest"
17
+ import * as HttpClientResponse from "effect/http/HttpClientResponse"
14
18
  import * as Layer from "effect/Layer"
15
19
  import * as Redacted from "effect/Redacted"
16
20
  import type { SchemaError } from "effect/Schema"
17
- import * as HttpClient from "effect/unstable/http/HttpClient"
18
- import type * as HttpClientError from "effect/unstable/http/HttpClientError"
19
- import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
20
- import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
21
21
  import * as Generated from "./generated/JiraApi.js"
22
- import { JiraApiConfig, type JiraApiConfigContract } from "./JiraApiConfig.js"
22
+ import { JiraApiConfig, type JiraApiConfigContract, type JiraApiCredential } from "./JiraApiConfig.js"
23
23
 
24
24
  export interface UploadAttachmentInput {
25
25
  readonly bytes: Uint8Array
@@ -34,26 +34,48 @@ export interface JiraApiClientContract extends Generated.JiraApi {
34
34
  ) => Effect.Effect<Generated.AddAttachment200, HttpClientError.HttpClientError | SchemaError>
35
35
  }
36
36
 
37
- const authorizationHeader = (config: JiraApiConfigContract): string =>
38
- config.auth.type === "basic"
39
- ? `Basic ${Encoding.encodeBase64(`${config.auth.email}:${Redacted.value(config.auth.apiToken)}`)}`
40
- : `Bearer ${Redacted.value(config.auth.accessToken)}`
37
+ const authorizationHeader = (auth: JiraApiCredential): string =>
38
+ auth.type === "basic"
39
+ ? `Basic ${Base64.encode(`${auth.email}:${Redacted.value(auth.apiToken)}`)}`
40
+ : `Bearer ${Redacted.value(auth.accessToken)}`
41
41
 
42
- const apiBaseUrl = (config: JiraApiConfigContract): string =>
43
- config.auth.type === "oauth2"
44
- ? `https://api.atlassian.com/ex/jira/${config.auth.cloudId}`
45
- : config.baseUrl
42
+ const apiBaseUrl = (baseUrl: string, auth: JiraApiCredential): string =>
43
+ auth.type === "oauth2" ? `https://api.atlassian.com/ex/jira/${auth.cloudId}` : auth.siteUrl ?? baseUrl
44
+
45
+ /**
46
+ * Where the credential is turned into a header.
47
+ *
48
+ * Both the host and the `Authorization` header come from `auth`, so they are derived together from
49
+ * one value rather than separately from two reads that could disagree about which site is being
50
+ * addressed. When `resolveAuth` is present that pair is recomputed per request, which is the only
51
+ * way a token refreshed mid-run reaches the wire — `mapRequest` would capture the startup value.
52
+ */
53
+ const addressAndAuthorize = (
54
+ config: JiraApiConfigContract
55
+ ): (client: HttpClient.HttpClient) => HttpClient.HttpClient => {
56
+ const resolve = config.resolveAuth
57
+ if (resolve === undefined) {
58
+ return HttpClient.mapRequest(flow(
59
+ HttpClientRequest.prependUrl(apiBaseUrl(config.baseUrl, config.auth)),
60
+ HttpClientRequest.setHeader("Authorization", authorizationHeader(config.auth))
61
+ ))
62
+ }
63
+ return HttpClient.mapRequestEffect((request) =>
64
+ Effect.map(resolve, (auth) =>
65
+ request.pipe(
66
+ HttpClientRequest.prependUrl(apiBaseUrl(config.baseUrl, auth)),
67
+ HttpClientRequest.setHeader("Authorization", authorizationHeader(auth))
68
+ ))
69
+ )
70
+ }
46
71
 
47
72
  export const make = (
48
73
  httpClient: HttpClient.HttpClient,
49
74
  config: JiraApiConfigContract
50
75
  ): Generated.JiraApi =>
51
76
  Generated.make(httpClient.pipe(
52
- HttpClient.mapRequest(flow(
53
- HttpClientRequest.prependUrl(apiBaseUrl(config)),
54
- HttpClientRequest.setHeader("Authorization", authorizationHeader(config)),
55
- HttpClientRequest.setHeader("Accept", "application/json")
56
- ))
77
+ addressAndAuthorize(config),
78
+ HttpClient.mapRequest(HttpClientRequest.setHeader("Accept", "application/json"))
57
79
  ))
58
80
 
59
81
  const makeUploadAttachment =
@@ -6,13 +6,30 @@
6
6
  * - **Discriminated auth union**: The `auth` field is `{ type: "basic", ... } | { type: "oauth2", ... }`.
7
7
  * Basic auth uses email + Redacted API token; OAuth2 uses Redacted access token + cloud ID.
8
8
  * - **Base URL routing**: For OAuth2, the base URL is derived from cloud ID (`api.atlassian.com/ex/jira/{cloudId}`);
9
- * for basic auth, `baseUrl` is used directly.
9
+ * for basic auth, the credential's `siteUrl` when set, otherwise `baseUrl`.
10
+ * - **`auth` is a snapshot; `resolveAuth` is a subscription.** A credential that can expire needs the
11
+ * second one — see its own note.
10
12
  *
11
13
  * @module
12
14
  */
13
15
  import * as Context from "effect/Context"
16
+ import type * as Effect from "effect/Effect"
14
17
  import type * as Redacted from "effect/Redacted"
15
18
 
19
+ /** How a request proves who is asking. */
20
+ export type JiraApiCredential =
21
+ | {
22
+ readonly type: "basic"
23
+ readonly email: string
24
+ readonly apiToken: Redacted.Redacted<string>
25
+ /**
26
+ * The site this token belongs to, such as `https://your-team.atlassian.net`. When set it is the
27
+ * request host instead of `baseUrl`, so a credential re-read through `resolveAuth` keeps its site.
28
+ */
29
+ readonly siteUrl?: string | undefined
30
+ }
31
+ | { readonly type: "oauth2"; readonly accessToken: Redacted.Redacted<string>; readonly cloudId: string }
32
+
16
33
  /**
17
34
  * Configuration shape for Jira API client.
18
35
  *
@@ -20,9 +37,21 @@ import type * as Redacted from "effect/Redacted"
20
37
  */
21
38
  export interface JiraApiConfigContract {
22
39
  readonly baseUrl: string
23
- readonly auth:
24
- | { readonly type: "basic"; readonly email: string; readonly apiToken: Redacted.Redacted<string> }
25
- | { readonly type: "oauth2"; readonly accessToken: Redacted.Redacted<string>; readonly cloudId: string }
40
+ readonly auth: JiraApiCredential
41
+ /**
42
+ * Re-read before every request, when supplied. Otherwise `auth` is used as given.
43
+ *
44
+ * A `Redacted` token is a value, so a client built from one is pinned to it for as long as the
45
+ * client lives. That is invisible in a command that exits in seconds and fatal in one that does
46
+ * not: an Atlassian access token lasts about an hour, after which every request 401s and no amount
47
+ * of retrying inside the process can recover, because the expired token is baked into the header.
48
+ * `jcf watch` is meant to run all day, so it supplies this and a refresh reaches the next request.
49
+ *
50
+ * Typed as infallible on purpose. This runs inside request preprocessing, where a new error would
51
+ * widen every operation's error channel; a resolver that cannot read its credential yields
52
+ * whatever it has, and the 401 that follows is the same one an absent login already produces.
53
+ */
54
+ readonly resolveAuth?: Effect.Effect<JiraApiCredential> | undefined
26
55
  }
27
56
 
28
57
  /**
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Optional struct keys that shadow `Object.prototype` members.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+ import * as Option from "effect/Option"
7
+ import * as Predicate from "effect/Predicate"
8
+ import * as Schema from "effect/Schema"
9
+ import * as SchemaGetter from "effect/SchemaGetter"
10
+
11
+ /** `Object.prototype` members that Jira payloads use as ordinary field names. */
12
+ export type PrototypeKey = "toString"
13
+
14
+ /**
15
+ * Optional key for a field named after an `Object.prototype` member, such as Jira's `toString`.
16
+ *
17
+ * Effect resolves declared struct keys with `in`, so a JSON object that omits `toString` presents the inherited
18
+ * `Object.prototype.toString`. Only that exact inherited function decodes as an absent key; any other value must
19
+ * satisfy `schema`. Use it in place of `Schema.optionalKey(schema)` for these keys; the encoded and decoded types
20
+ * stay those of `Schema.optionalKey(schema)`.
21
+ */
22
+ export const ownOptionalKey = <S extends Schema.Top>(key: PrototypeKey, schema: S) => {
23
+ const inherited = Object.prototype[key]
24
+ // Typed `never`: the inherited member is not a domain value and never survives decoding.
25
+ const isInherited = Predicate.compose(
26
+ Predicate.isFunction,
27
+ (member: Function): member is never => member === inherited
28
+ )
29
+ const InheritedMember = Schema.declare(isInherited, { expected: `the inherited Object.prototype.${key}` })
30
+ return Schema.optionalKey(Schema.Union([schema, InheritedMember])).pipe(
31
+ Schema.decodeTo(Schema.optionalKey(Schema.toType(schema)), {
32
+ decode: SchemaGetter.transformOptional(Option.filter(Predicate.not(isInherited))),
33
+ encode: SchemaGetter.passthrough()
34
+ })
35
+ )
36
+ }