@knpkv/jira-api-client 1.0.1 → 2.0.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/src/index.ts CHANGED
@@ -4,8 +4,10 @@
4
4
  * @packageDocumentation
5
5
  */
6
6
 
7
- export { JiraApiClient, type JiraApiClientShape, layer, make, type UploadAttachmentInput } from "./JiraApiClient.js"
7
+ export { JiraApiClient, type JiraApiClientContract, layer, make, type UploadAttachmentInput } from "./JiraApiClient.js"
8
8
 
9
- export { JiraApiConfig, type JiraApiConfigShape } from "./JiraApiConfig.js"
9
+ export { JiraApiConfig, type JiraApiConfigContract, type JiraApiCredential } from "./JiraApiConfig.js"
10
10
 
11
11
  export * as JiraApi from "./generated/JiraApi.js"
12
+
13
+ export { ownOptionalKey, type PrototypeKey } from "./OwnOptionalKey.js"
@@ -1,14 +1,14 @@
1
1
  import { describe, expect, it } from "@effect/vitest"
2
2
  import * as Effect from "effect/Effect"
3
+ import * as HttpClient from "effect/http/HttpClient"
4
+ import type * as HttpClientRequest from "effect/http/HttpClientRequest"
5
+ import * as HttpClientResponse from "effect/http/HttpClientResponse"
3
6
  import * as Layer from "effect/Layer"
4
7
  import * as Redacted from "effect/Redacted"
5
- import * as HttpClient from "effect/unstable/http/HttpClient"
6
- import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
7
- import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
8
- import { JiraApiClient, JiraApiConfig, type JiraApiConfigShape } from "../src/index.js"
8
+ import { JiraApiClient, JiraApiConfig, type JiraApiConfigContract } from "../src/index.js"
9
9
 
10
10
  const clientLayer = (
11
- config: JiraApiConfigShape,
11
+ config: JiraApiConfigContract,
12
12
  response: { readonly status: number; readonly body?: unknown },
13
13
  requests: Array<HttpClientRequest.HttpClientRequest>
14
14
  ) =>
@@ -31,7 +31,7 @@ const clientLayer = (
31
31
  ))
32
32
  )
33
33
 
34
- const basicConfig: JiraApiConfigShape = {
34
+ const basicConfig: JiraApiConfigContract = {
35
35
  baseUrl: "https://jira.test",
36
36
  auth: {
37
37
  type: "basic",
@@ -0,0 +1,103 @@
1
+ /**
2
+ * How a request proves who is asking, and when that proof is re-read.
3
+ *
4
+ * These build the client with {@link make} rather than through its Layer, because the credential is
5
+ * resolved inside request preprocessing — the thing under test is what reaches the wire on the
6
+ * *second* call, which needs nothing from the environment to observe.
7
+ */
8
+ import { describe, expect, it } from "@effect/vitest"
9
+ import * as Effect from "effect/Effect"
10
+ import { Base64 } from "effect/encoding"
11
+ import * as HttpClient from "effect/http/HttpClient"
12
+ import type * as HttpClientRequest from "effect/http/HttpClientRequest"
13
+ import * as HttpClientResponse from "effect/http/HttpClientResponse"
14
+ import * as Redacted from "effect/Redacted"
15
+ import { type JiraApiCredential, make } from "../src/index.js"
16
+
17
+ /** An http client that answers every request the same way and records what it was asked. */
18
+ const recording = (requests: Array<HttpClientRequest.HttpClientRequest>) =>
19
+ HttpClient.make((request) =>
20
+ Effect.sync(() => {
21
+ requests.push(request)
22
+ return HttpClientResponse.fromWeb(
23
+ request,
24
+ new Response(JSON.stringify({ id: "10001", key: "PROJ-1", fields: {} }), {
25
+ status: 200,
26
+ headers: { "content-type": "application/json" }
27
+ })
28
+ )
29
+ })
30
+ )
31
+
32
+ const staleCredential: JiraApiCredential = {
33
+ type: "oauth2",
34
+ accessToken: Redacted.make("stale"),
35
+ cloudId: "cloud-123"
36
+ }
37
+
38
+ describe("JiraApiClient credentials", () => {
39
+ // A client is built once and can outlive its credential: `jcf watch` runs all day on an access
40
+ // token good for about an hour. Before this, the token was read once when the client was built and
41
+ // baked into a header, so every request after the first expiry 401ed for the life of the process.
42
+ it.effect("re-reads a refreshing credential on every request", () =>
43
+ Effect.gen(function*() {
44
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
45
+ let issued = 0
46
+ const client = make(recording(requests), {
47
+ baseUrl: "",
48
+ auth: staleCredential,
49
+ resolveAuth: Effect.sync(() => {
50
+ issued += 1
51
+ return { type: "oauth2", accessToken: Redacted.make(`token-${issued}`), cloudId: "cloud-123" }
52
+ })
53
+ })
54
+
55
+ yield* client.getIssue("PROJ-1", undefined)
56
+ yield* client.getIssue("PROJ-1", undefined)
57
+
58
+ expect(requests.map((request) => request.headers.authorization)).toEqual([
59
+ "Bearer token-1",
60
+ "Bearer token-2"
61
+ ])
62
+ }))
63
+
64
+ // The host comes from the same value as the header, so a resolver cannot address one site while
65
+ // authenticating against another.
66
+ it.effect("routes a refreshed credential at its own cloud id", () =>
67
+ Effect.gen(function*() {
68
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
69
+ const client = make(recording(requests), {
70
+ baseUrl: "",
71
+ auth: staleCredential,
72
+ resolveAuth: Effect.succeed({
73
+ type: "oauth2",
74
+ accessToken: Redacted.make("fresh"),
75
+ cloudId: "cloud-999"
76
+ })
77
+ })
78
+
79
+ yield* client.getIssue("PROJ-1", undefined)
80
+
81
+ expect(requests[0]?.url).toBe("https://api.atlassian.com/ex/jira/cloud-999/rest/api/3/issue/PROJ-1")
82
+ expect(requests[0]?.headers.authorization).toBe("Bearer fresh")
83
+ }))
84
+
85
+ // Omitting `resolveAuth` must keep the previous behaviour exactly — right for a basic-auth API
86
+ // token, which cannot expire and so has nothing to re-read.
87
+ it.effect("uses the given credential unchanged when no resolver is supplied", () =>
88
+ Effect.gen(function*() {
89
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
90
+ const client = make(recording(requests), {
91
+ baseUrl: "https://jira.test",
92
+ auth: { type: "basic", email: "user@example.com", apiToken: Redacted.make("test-token") }
93
+ })
94
+
95
+ yield* client.getIssue("PROJ-1", undefined)
96
+ yield* client.getIssue("PROJ-1", undefined)
97
+
98
+ // The exact RFC 7617 value for the configured email and token, on every request.
99
+ const expected = `Basic ${Base64.encode("user@example.com:test-token")}`
100
+ expect(requests[0]?.url).toBe("https://jira.test/rest/api/3/issue/PROJ-1")
101
+ expect(requests.map((request) => request.headers.authorization)).toEqual([expected, expected])
102
+ }))
103
+ })
@@ -0,0 +1,24 @@
1
+ import { assert, describe, it } from "@effect/vitest"
2
+ import * as Schema from "effect/Schema"
3
+ import { JiraApi } from "../src/index.js"
4
+
5
+ const decodeChange = Schema.decodeUnknownSync(JiraApi.ChangeDetails)
6
+
7
+ describe("ownOptionalKey", () => {
8
+ // Effect reads declared keys with `in`; JSON omitting `toString` must not decode Object.prototype.toString.
9
+ it("decodes an omitted toString as an absent key", () => {
10
+ const change = decodeChange(JSON.parse(`{"field":"Comment","fromString":"Previous comment"}`))
11
+ assert.isFalse(Object.hasOwn(change, "toString"))
12
+ assert.deepStrictEqual({ ...change }, { field: "Comment", fromString: "Previous comment" })
13
+ })
14
+
15
+ it("keeps an own toString value", () => {
16
+ const change = decodeChange(JSON.parse(`{"field":"status","toString":"Done"}`))
17
+ assert.isTrue(Object.hasOwn(change, "toString"))
18
+ assert.strictEqual(change.toString, "Done")
19
+ })
20
+
21
+ it("rejects an own non-string toString", () => {
22
+ assert.throws(() => decodeChange(JSON.parse(`{"field":"status","toString":42}`)))
23
+ })
24
+ })
@@ -0,0 +1,26 @@
1
+ import { assert, describe, it } from "@effect/vitest"
2
+ import * as Schema from "effect/Schema"
3
+ import { JiraApi } from "../src/index.js"
4
+
5
+ // @effect/openapi-generator 4.0.0 emits Schema.Never for these recursive unions; the committed client keeps them usable.
6
+ describe("recursive generated schemas", () => {
7
+ it("decodes a nested JQL query clause", () => {
8
+ const clause = {
9
+ clauses: [
10
+ { field: { name: "project" }, operator: "=", operand: { value: "KNP" } },
11
+ { clauses: [{ field: { name: "status" }, operator: "!=", operand: { value: "Done" } }], operator: "not" }
12
+ ],
13
+ operator: "and"
14
+ }
15
+ assert.deepStrictEqual(Schema.decodeUnknownSync(JiraApi.JqlQueryClause)(clause), clause)
16
+ })
17
+
18
+ it("decodes a nested workflow condition tree", () => {
19
+ const condition = {
20
+ conditions: [{ nodeType: "simple", type: "PermissionCondition", configuration: { permissionKey: "BROWSE" } }],
21
+ nodeType: "compound",
22
+ operator: "AND"
23
+ }
24
+ assert.deepStrictEqual(Schema.decodeUnknownSync(JiraApi.WorkflowCondition)(condition), condition)
25
+ })
26
+ })