@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/src/index.ts CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  export { JiraApiClient, type JiraApiClientContract, layer, make, type UploadAttachmentInput } from "./JiraApiClient.js"
8
8
 
9
- export { JiraApiConfig, type JiraApiConfigContract } 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,10 +1,10 @@
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
8
  import { JiraApiClient, JiraApiConfig, type JiraApiConfigContract } from "../src/index.js"
9
9
 
10
10
  const clientLayer = (
@@ -0,0 +1,125 @@
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
+
104
+ // A resolver can hand back an API token for whichever site was connected since the client was
105
+ // built (jcf-web runs while `jcf auth jira token` changes the site), so the host travels with it.
106
+ it.effect("routes a resolved API token at its own site", () =>
107
+ Effect.gen(function*() {
108
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
109
+ const client = make(recording(requests), {
110
+ baseUrl: "",
111
+ auth: staleCredential,
112
+ resolveAuth: Effect.succeed({
113
+ type: "basic",
114
+ email: "user@example.com",
115
+ apiToken: Redacted.make("test-token"),
116
+ siteUrl: "https://team.atlassian.net"
117
+ })
118
+ })
119
+
120
+ yield* client.getIssue("PROJ-1", undefined)
121
+
122
+ expect(requests[0]?.url).toBe("https://team.atlassian.net/rest/api/3/issue/PROJ-1")
123
+ expect(requests[0]?.headers.authorization).toBe(`Basic ${Base64.encode("user@example.com:test-token")}`)
124
+ }))
125
+ })
@@ -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: typeof JiraApi.JqlQueryClause.Encoded = {
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: typeof JiraApi.WorkflowCondition.Encoded = {
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
+ })
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "../tsconfig.json",
3
+ "compilerOptions": { "noEmit": true, "composite": false, "rootDir": "..", "outDir": null, "tsBuildInfoFile": null },
4
+ "include": ["../src", "."],
5
+ "exclude": []
6
+ }
package/vitest.config.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { defineConfig } from "vitest/config"
2
+ import { workspaceSourceAlias } from "../../vitest.workspace-sources.ts"
2
3
 
3
4
  export default defineConfig({
5
+ resolve: { alias: [...workspaceSourceAlias] },
4
6
  test: {
5
7
  include: ["test/**/*.test.ts"]
6
8
  }