@knpkv/jira-api-client 0.4.0 → 1.0.1

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +88 -65
  3. package/dist/JiraApiClient.d.ts +20 -86
  4. package/dist/JiraApiClient.d.ts.map +1 -1
  5. package/dist/JiraApiClient.js +26 -75
  6. package/dist/JiraApiClient.js.map +1 -1
  7. package/dist/generated/JiraApi.d.ts +103977 -0
  8. package/dist/generated/JiraApi.d.ts.map +1 -0
  9. package/dist/generated/JiraApi.js +7608 -0
  10. package/dist/generated/JiraApi.js.map +1 -0
  11. package/dist/index.d.ts +3 -12
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -11
  14. package/dist/index.js.map +1 -1
  15. package/package.json +13 -16
  16. package/scripts/regenerate.ts +209 -201
  17. package/scripts/tsconfig.json +13 -0
  18. package/src/JiraApiClient.ts +74 -104
  19. package/src/generated/JiraApi.ts +19596 -0
  20. package/src/index.ts +3 -14
  21. package/test/JiraApiClient.test.ts +154 -153
  22. package/.specs/VERSION_V3 +0 -1
  23. package/.specs/jira-v3-1001.0.0-SNAPSHOT-05f20e18d9bde3de68f911b4915970482f536d4e.json +0 -56275
  24. package/.specs/jira-v3-1001.0.0-SNAPSHOT-d7cd03e966bb408ad32ffe870114289c528f56fd.json +0 -56129
  25. package/.specs/jira-v3-1001.0.0-SNAPSHOT-ea9913a8c4412d2bfb1911480b023a04544e19d8.json +0 -55618
  26. package/dist/OpenApiFetchClient.d.ts +0 -55
  27. package/dist/OpenApiFetchClient.d.ts.map +0 -1
  28. package/dist/OpenApiFetchClient.js +0 -68
  29. package/dist/OpenApiFetchClient.js.map +0 -1
  30. package/dist/generated/v3/index.d.ts +0 -7
  31. package/dist/generated/v3/index.d.ts.map +0 -1
  32. package/dist/generated/v3/index.js +0 -2
  33. package/dist/generated/v3/index.js.map +0 -1
  34. package/dist/generated/v3/schema.d.ts +0 -64454
  35. package/src/OpenApiFetchClient.ts +0 -102
  36. package/src/generated/v3/index.ts +0 -6
  37. package/src/generated/v3/schema.d.ts +0 -64454
  38. package/tsconfig.tsbuildinfo +0 -1
package/src/index.ts CHANGED
@@ -1,22 +1,11 @@
1
1
  /**
2
- * Root barrel export for `@knpkv/jira-api-client` -- openapi-fetch + Effect Jira REST client.
3
- *
4
- * **Mental model**
5
- *
6
- * - **Namespaced version**: `V3` re-exports the generated OpenAPI types (paths, components, operations).
7
- * - **Config + client split**: {@link JiraApiConfig} is provided separately from the
8
- * generated client to support auth-polymorphic usage.
9
- * - **openapi-fetch wrapper**: {@link OpenApiFetchClient} provides Effect-based `execute` method
10
- * around raw openapi-fetch clients.
2
+ * Schema-validated Effect client for Jira Cloud REST API v3.
11
3
  *
12
4
  * @packageDocumentation
13
5
  */
14
6
 
15
- export { JiraApiClient, type JiraApiClientShape, layer } from "./JiraApiClient.js"
7
+ export { JiraApiClient, type JiraApiClientShape, layer, make, type UploadAttachmentInput } from "./JiraApiClient.js"
16
8
 
17
9
  export { JiraApiConfig, type JiraApiConfigShape } from "./JiraApiConfig.js"
18
10
 
19
- export { FetchClientError, type OpenApiFetchClient, type SuccessData, toEffect } from "./OpenApiFetchClient.js"
20
-
21
- // Re-export generated V3 types
22
- export type * as V3 from "./generated/v3/index.js"
11
+ export * as JiraApi from "./generated/JiraApi.js"
@@ -1,169 +1,170 @@
1
- import { describe, expect, it, vi } from "@effect/vitest"
1
+ import { describe, expect, it } from "@effect/vitest"
2
2
  import * as Effect from "effect/Effect"
3
3
  import * as Layer from "effect/Layer"
4
- import * as Predicate from "effect/Predicate"
5
4
  import * as Redacted from "effect/Redacted"
6
- import { FetchClientError, JiraApiClient, JiraApiConfig, toEffect } from "../src/index.js"
7
-
8
- const isRequest = (input: RequestInfo | URL): input is Request =>
9
- Predicate.hasProperty(input, "url") &&
10
- Predicate.hasProperty(input, "headers") &&
11
- Predicate.hasProperty(input.headers, "entries") &&
12
- typeof input.headers.entries === "function"
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"
9
+
10
+ const clientLayer = (
11
+ config: JiraApiConfigShape,
12
+ response: { readonly status: number; readonly body?: unknown },
13
+ requests: Array<HttpClientRequest.HttpClientRequest>
14
+ ) =>
15
+ JiraApiClient.layer.pipe(
16
+ Layer.provide(Layer.succeed(JiraApiConfig, config)),
17
+ Layer.provide(Layer.succeed(
18
+ HttpClient.HttpClient,
19
+ HttpClient.make((request) =>
20
+ Effect.sync(() => {
21
+ requests.push(request)
22
+ return HttpClientResponse.fromWeb(
23
+ request,
24
+ new Response(response.body === undefined ? null : JSON.stringify(response.body), {
25
+ status: response.status,
26
+ headers: { "content-type": "application/json" }
27
+ })
28
+ )
29
+ })
30
+ )
31
+ ))
32
+ )
13
33
 
14
- const headersRecord = (headers: HeadersInit | undefined): Record<string, string> => {
15
- if (headers === undefined) return {}
16
- if (
17
- Predicate.hasProperty(headers, "entries") &&
18
- typeof headers.entries === "function"
19
- ) {
20
- return Object.fromEntries(headers.entries())
34
+ const basicConfig: JiraApiConfigShape = {
35
+ baseUrl: "https://jira.test",
36
+ auth: {
37
+ type: "basic",
38
+ email: "user@example.com",
39
+ apiToken: Redacted.make("test-token")
21
40
  }
22
- if (Array.isArray(headers)) return Object.fromEntries(headers)
23
- return Object.fromEntries(Object.entries(headers))
24
- }
25
-
26
- const authorizationHeader = (headers: HeadersInit | undefined): string | undefined => {
27
- const record = headersRecord(headers)
28
- return record.Authorization ?? record.authorization
29
- }
30
-
31
- /**
32
- * Mock global fetch to capture requests and return canned responses.
33
- */
34
- const withMockFetch = <A, E>(
35
- responses: Array<{ status: number; body: unknown }>,
36
- fn: (capturedRequests: Array<{ url: string; init: RequestInit }>) => Effect.Effect<A, E>
37
- ): Effect.Effect<A, E> => {
38
- const capturedRequests: Array<{ url: string; init: RequestInit }> = []
39
- let requestIndex = 0
40
- const originalFetch = fetch
41
-
42
- vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
43
- const url = typeof input === "string" ? input : isRequest(input) ? input.url : input.toString()
44
- const headers = isRequest(input)
45
- ? Object.fromEntries(input.headers.entries())
46
- : headersRecord(init?.headers)
47
- capturedRequests.push({ url, init: { ...init, headers } })
48
- const response = responses[requestIndex] ?? { status: 200, body: {} }
49
- requestIndex++
50
- return new Response(JSON.stringify(response.body), {
51
- status: response.status,
52
- headers: { "content-type": "application/json" }
53
- })
54
- })
55
-
56
- return Effect.ensuring(
57
- fn(capturedRequests),
58
- Effect.sync(() => {
59
- vi.stubGlobal("fetch", originalFetch)
60
- })
61
- )
62
41
  }
63
42
 
64
43
  describe("JiraApiClient", () => {
65
- describe("layer construction", () => {
66
- // Verifies basic auth applies Base64-encoded email:token as Authorization header and routes to baseUrl
67
- it.effect("creates client with basic auth", () =>
68
- withMockFetch(
69
- [{ status: 200, body: { id: "10001", key: "PROJ-1", fields: { summary: "Test" } } }],
70
- (capturedRequests) =>
71
- Effect.gen(function*() {
72
- const configLayer = Layer.succeed(JiraApiConfig, {
73
- baseUrl: "https://test.atlassian.net",
74
- auth: {
75
- type: "basic",
76
- email: "user@example.com",
77
- apiToken: Redacted.make("test-token")
78
- }
79
- })
80
-
81
- const client = yield* JiraApiClient.pipe(
82
- Effect.provide(JiraApiClient.layer),
83
- Effect.provide(configLayer)
84
- )
85
-
86
- const issue = yield* toEffect(client.v3.client.GET("/rest/api/3/issue/{issueIdOrKey}", {
87
- params: { path: { issueIdOrKey: "PROJ-1" } }
88
- }))
89
-
90
- expect(issue.id).toBe("10001")
91
- expect(issue.key).toBe("PROJ-1")
92
- expect(capturedRequests).toHaveLength(1)
93
-
94
- const request = capturedRequests[0]!
95
- expect(request.url).toContain("test.atlassian.net")
96
- expect(request.url).toContain("/rest/api/3/issue/PROJ-1")
97
- const authHeader = authorizationHeader(request.init.headers)
98
- expect(authHeader).toMatch(/^Basic /)
99
- })
100
- ))
101
-
102
- // Verifies OAuth2 applies Bearer token and routes through Atlassian cloud proxy
103
- it.effect("creates client with OAuth2 auth and applies Bearer token", () =>
104
- withMockFetch(
105
- [{ status: 200, body: { id: "10001", key: "PROJ-1", fields: { summary: "Test" } } }],
106
- (capturedRequests) =>
107
- Effect.gen(function*() {
108
- const configLayer = Layer.succeed(JiraApiConfig, {
109
- baseUrl: "https://test.atlassian.net",
110
- auth: {
111
- type: "oauth2",
112
- accessToken: Redacted.make("oauth-token"),
113
- cloudId: "cloud-123"
114
- }
115
- })
116
-
117
- const client = yield* JiraApiClient.pipe(
118
- Effect.provide(JiraApiClient.layer),
119
- Effect.provide(configLayer)
120
- )
121
-
122
- const issue = yield* toEffect(client.v3.client.GET("/rest/api/3/issue/{issueIdOrKey}", {
123
- params: { path: { issueIdOrKey: "PROJ-1" } }
124
- }))
125
-
126
- expect(issue.id).toBe("10001")
127
- expect(capturedRequests).toHaveLength(1)
44
+ it.effect("applies basic auth and decodes dynamic issue fields", () => {
45
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
46
+ return Effect.gen(function*() {
47
+ const client = yield* JiraApiClient
48
+ const issue = yield* client.getIssue("PROJ-1", undefined)
49
+ expect(issue.key).toBe("PROJ-1")
50
+ expect(issue.fields?.customfield_10001).toBe("dynamic")
51
+ expect(requests[0]?.url).toBe("https://jira.test/rest/api/3/issue/PROJ-1")
52
+ expect(requests[0]?.headers.authorization).toMatch(/^Basic /)
53
+ }).pipe(Effect.provide(clientLayer(basicConfig, {
54
+ status: 200,
55
+ body: { id: "10001", key: "PROJ-1", fields: { customfield_10001: "dynamic" } }
56
+ }, requests)))
57
+ })
128
58
 
129
- const request = capturedRequests[0]!
130
- expect(request.url).toContain("api.atlassian.com/ex/jira/cloud-123")
131
- const authHeader = authorizationHeader(request.init.headers)
132
- expect(authHeader).toBe("Bearer oauth-token")
133
- })
134
- ))
59
+ it.effect("routes OAuth2 through the Atlassian cloud gateway", () => {
60
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
61
+ return Effect.gen(function*() {
62
+ const client = yield* JiraApiClient
63
+ yield* client.getIssue("PROJ-1", undefined)
64
+ expect(requests[0]?.url).toBe("https://api.atlassian.com/ex/jira/cloud-123/rest/api/3/issue/PROJ-1")
65
+ expect(requests[0]?.headers.authorization).toBe("Bearer oauth-token")
66
+ }).pipe(Effect.provide(clientLayer(
67
+ {
68
+ baseUrl: "",
69
+ auth: { type: "oauth2", accessToken: Redacted.make("oauth-token"), cloudId: "cloud-123" }
70
+ },
71
+ { status: 200, body: { id: "10001", key: "PROJ-1", fields: {} } },
72
+ requests
73
+ )))
135
74
  })
136
75
 
137
- describe("V3 client", () => {
138
- // API errors must propagate as FetchClientError
139
- it.effect("handles API errors", () =>
140
- withMockFetch(
141
- [{ status: 404, body: { errorMessages: ["Issue does not exist"] } }],
142
- () =>
143
- Effect.gen(function*() {
144
- const configLayer = Layer.succeed(JiraApiConfig, {
145
- baseUrl: "https://test.atlassian.net",
146
- auth: { type: "basic", email: "u@e.com", apiToken: Redacted.make("t") }
147
- })
76
+ it.effect("keeps bodyless-spec 404 responses in the typed error channel", () =>
77
+ Effect.gen(function*() {
78
+ const client = yield* JiraApiClient
79
+ const result = yield* Effect.result(client.getIssue("NOPE-999", undefined))
80
+ expect(result._tag).toBe("Failure")
81
+ if (result._tag === "Failure") expect(result.failure._tag).toBe("GetIssue404")
82
+ }).pipe(Effect.provide(clientLayer(basicConfig, {
83
+ status: 404,
84
+ body: { errorMessages: ["Issue does not exist"] }
85
+ }, []))))
86
+
87
+ it.effect("never treats a genuinely empty 404 as success", () =>
88
+ Effect.gen(function*() {
89
+ const client = yield* JiraApiClient
90
+ const result = yield* Effect.result(client.getIssue("NOPE-EMPTY", undefined))
91
+ expect(result._tag).toBe("Failure")
92
+ }).pipe(Effect.provide(clientLayer(basicConfig, { status: 404 }, []))))
93
+
94
+ it.effect("decodes a terminal JQL page whose nextPageToken is null", () =>
95
+ Effect.gen(function*() {
96
+ const client = yield* JiraApiClient
97
+ const result = yield* client.searchIssuesUsingJql({ params: { jql: "project = PROJ" } })
98
+ expect(result.nextPageToken).toBeNull()
99
+ expect(result.isLast).toBe(true)
100
+ }).pipe(Effect.provide(clientLayer(basicConfig, {
101
+ status: 200,
102
+ body: { issues: [], isLast: true, nextPageToken: null }
103
+ }, []))))
104
+
105
+ it.effect("preserves Jira Premium contributors on decoded versions", () =>
106
+ Effect.gen(function*() {
107
+ const client = yield* JiraApiClient
108
+ const version = yield* client.getVersion("10000", undefined)
109
+ expect(version.contributors).toEqual([
110
+ "account-1",
111
+ { accountId: "account-2", displayName: "Ada" }
112
+ ])
113
+ }).pipe(Effect.provide(clientLayer(basicConfig, {
114
+ status: 200,
115
+ body: {
116
+ id: "10000",
117
+ name: "1.0.0",
118
+ contributors: ["account-1", { accountId: "account-2", displayName: "Ada" }]
119
+ }
120
+ }, []))))
121
+
122
+ it.effect("rejects malformed successful responses", () =>
123
+ Effect.gen(function*() {
124
+ const client = yield* JiraApiClient
125
+ const result = yield* Effect.result(client.getFields(undefined))
126
+ expect(result._tag).toBe("Failure")
127
+ }).pipe(Effect.provide(clientLayer(basicConfig, { status: 200, body: {} }, []))))
128
+
129
+ it.effect("returns void for Jira 204 responses that advertise an empty JSON schema", () => {
130
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
131
+ return Effect.gen(function*() {
132
+ const client = yield* JiraApiClient
133
+ const result = yield* client.setBanner({
134
+ payload: { isEnabled: true, message: "Maintenance" }
135
+ })
136
+
137
+ expect(result).toBeUndefined()
138
+ expect(requests).toHaveLength(1)
139
+ }).pipe(Effect.provide(clientLayer(basicConfig, { status: 204 }, requests)))
140
+ })
148
141
 
149
- const client = yield* JiraApiClient.pipe(
150
- Effect.provide(JiraApiClient.layer),
151
- Effect.provide(configLayer)
152
- )
142
+ it.effect("keeps naturally bodyless delete operations void", () => {
143
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
144
+ return Effect.gen(function*() {
145
+ const client = yield* JiraApiClient
146
+ const result = yield* client.deleteIssue("PROJ-1", undefined)
153
147
 
154
- const result = yield* toEffect(client.v3.client.GET("/rest/api/3/issue/{issueIdOrKey}", {
155
- params: { path: { issueIdOrKey: "NOPE-999" } }
156
- }))
157
- .pipe(Effect.result)
148
+ expect(result).toBeUndefined()
149
+ expect(requests).toHaveLength(1)
150
+ }).pipe(Effect.provide(clientLayer(basicConfig, { status: 204 }, requests)))
151
+ })
158
152
 
159
- expect(result._tag).toBe("Failure")
160
- if (result._tag === "Failure") {
161
- expect(result.failure).toBeInstanceOf(FetchClientError)
162
- if (Predicate.hasProperty(result.failure, "status")) {
163
- expect(result.failure.status).toBe(404)
164
- }
165
- }
166
- })
167
- ))
153
+ it.effect("uploads multipart data with the Atlassian CSRF bypass header", () => {
154
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
155
+ return Effect.gen(function*() {
156
+ const client = yield* JiraApiClient
157
+ const attachments = yield* client.uploadAttachment("PROJ-1", {
158
+ bytes: new Uint8Array([1, 2, 3]),
159
+ filename: "example.bin",
160
+ mediaType: "application/octet-stream"
161
+ })
162
+ expect(attachments[0]?.filename).toBe("example.bin")
163
+ expect(requests[0]?.headers["x-atlassian-token"]).toBe("no-check")
164
+ expect(requests[0]?.body._tag).toBe("FormData")
165
+ }).pipe(Effect.provide(clientLayer(basicConfig, {
166
+ status: 200,
167
+ body: [{ id: "1", filename: "example.bin", size: 3 }]
168
+ }, requests)))
168
169
  })
169
170
  })
package/.specs/VERSION_V3 DELETED
@@ -1 +0,0 @@
1
- 1001.0.0-SNAPSHOT-05f20e18d9bde3de68f911b4915970482f536d4e