@knpkv/jira-api-client 0.2.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/.specs/VERSION_V3 +1 -0
- package/.specs/jira-v3-1001.0.0-SNAPSHOT-ea9913a8c4412d2bfb1911480b023a04544e19d8.json +55618 -0
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/dist/JiraApiClient.d.ts +99 -0
- package/dist/JiraApiClient.d.ts.map +1 -0
- package/dist/JiraApiClient.js +94 -0
- package/dist/JiraApiClient.js.map +1 -0
- package/dist/JiraApiConfig.d.ts +57 -0
- package/dist/JiraApiConfig.d.ts.map +1 -0
- package/dist/JiraApiConfig.js +37 -0
- package/dist/JiraApiConfig.js.map +1 -0
- package/dist/OpenApiFetchClient.d.ts +55 -0
- package/dist/OpenApiFetchClient.d.ts.map +1 -0
- package/dist/OpenApiFetchClient.js +63 -0
- package/dist/OpenApiFetchClient.js.map +1 -0
- package/dist/generated/v3/index.d.ts +7 -0
- package/dist/generated/v3/index.d.ts.map +1 -0
- package/dist/generated/v3/index.js +2 -0
- package/dist/generated/v3/index.js.map +1 -0
- package/dist/generated/v3/schema.d.ts +64353 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/package.json +78 -0
- package/scripts/regenerate.ts +141 -0
- package/src/JiraApiClient.ts +129 -0
- package/src/JiraApiConfig.ts +51 -0
- package/src/OpenApiFetchClient.ts +92 -0
- package/src/generated/v3/index.ts +6 -0
- package/src/generated/v3/schema.d.ts +64353 -0
- package/src/index.ts +22 -0
- package/test/JiraApiClient.test.ts +145 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +7 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
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.
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
export { JiraApiClient, type JiraApiClientShape, layer } from "./JiraApiClient.js";
|
|
15
|
+
export { JiraApiConfig, type JiraApiConfigShape } from "./JiraApiConfig.js";
|
|
16
|
+
export { FetchClientError, type OpenApiFetchClient, type SuccessData, toEffect } from "./OpenApiFetchClient.js";
|
|
17
|
+
export type * as V3 from "./generated/v3/index.js";
|
|
18
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAA;AAElF,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,oBAAoB,CAAA;AAE3E,OAAO,EAAE,gBAAgB,EAAE,KAAK,kBAAkB,EAAE,KAAK,WAAW,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAA;AAG/G,YAAY,KAAK,EAAE,MAAM,yBAAyB,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
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.
|
|
11
|
+
*
|
|
12
|
+
* @packageDocumentation
|
|
13
|
+
*/
|
|
14
|
+
export { JiraApiClient, layer } from "./JiraApiClient.js";
|
|
15
|
+
export { JiraApiConfig } from "./JiraApiConfig.js";
|
|
16
|
+
export { FetchClientError, toEffect } from "./OpenApiFetchClient.js";
|
|
17
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,aAAa,EAA2B,KAAK,EAAE,MAAM,oBAAoB,CAAA;AAElF,OAAO,EAAE,aAAa,EAA2B,MAAM,oBAAoB,CAAA;AAE3E,OAAO,EAAE,gBAAgB,EAA6C,QAAQ,EAAE,MAAM,yBAAyB,CAAA"}
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@knpkv/jira-api-client",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Effect-based Jira Cloud REST API v3 client",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "knpkv",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/knpkv/npm.git",
|
|
10
|
+
"directory": "packages/jira-api-client"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/knpkv/npm/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/knpkv/npm/tree/main/packages/jira-api-client#readme",
|
|
16
|
+
"type": "module",
|
|
17
|
+
"sideEffects": false,
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"import": "./dist/index.js",
|
|
23
|
+
"types": "./dist/index.d.ts"
|
|
24
|
+
},
|
|
25
|
+
"./JiraApiClient": {
|
|
26
|
+
"import": "./dist/JiraApiClient.js",
|
|
27
|
+
"types": "./dist/JiraApiClient.d.ts"
|
|
28
|
+
},
|
|
29
|
+
"./JiraApiConfig": {
|
|
30
|
+
"import": "./dist/JiraApiConfig.js",
|
|
31
|
+
"types": "./dist/JiraApiConfig.d.ts"
|
|
32
|
+
},
|
|
33
|
+
"./v3": {
|
|
34
|
+
"import": "./dist/generated/v3/index.js",
|
|
35
|
+
"types": "./dist/generated/v3/index.d.ts"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"effect": "^3.19.3"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@effect/cli": "latest",
|
|
46
|
+
"@effect/platform-node": "latest",
|
|
47
|
+
"@effect/vitest": "latest",
|
|
48
|
+
"@types/node": "latest",
|
|
49
|
+
"effect": "latest",
|
|
50
|
+
"openapi-typescript": "^7.13.0",
|
|
51
|
+
"openapi-typescript-helpers": "^0.1.0",
|
|
52
|
+
"tsx": "^4.19.4",
|
|
53
|
+
"typescript": "~5.9.0",
|
|
54
|
+
"vitest": "^4.0.13"
|
|
55
|
+
},
|
|
56
|
+
"keywords": [
|
|
57
|
+
"effect",
|
|
58
|
+
"effect-ts",
|
|
59
|
+
"jira",
|
|
60
|
+
"atlassian",
|
|
61
|
+
"api-client",
|
|
62
|
+
"openapi"
|
|
63
|
+
],
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"openapi-fetch": "^0.17.0"
|
|
66
|
+
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"build": "tsc && cp src/generated/v3/schema.d.ts dist/generated/v3/schema.d.ts",
|
|
69
|
+
"clean": "rimraf dist dist-test .tsbuildinfo",
|
|
70
|
+
"test": "vitest run",
|
|
71
|
+
"test:watch": "vitest",
|
|
72
|
+
"lint": "eslint src",
|
|
73
|
+
"lint:fix": "eslint src --fix",
|
|
74
|
+
"check": "tsc --noEmit",
|
|
75
|
+
"regenerate": "tsx scripts/regenerate.ts",
|
|
76
|
+
"regenerate:check": "tsx scripts/regenerate.ts --check"
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Regeneration script for Jira API types.
|
|
4
|
+
*
|
|
5
|
+
* Fetches OpenAPI specs from Atlassian and regenerates openapi-typescript types.
|
|
6
|
+
*/
|
|
7
|
+
import { Command, Options } from "@effect/cli"
|
|
8
|
+
import { NodeContext, NodeRuntime } from "@effect/platform-node"
|
|
9
|
+
import * as Console from "effect/Console"
|
|
10
|
+
import * as Effect from "effect/Effect"
|
|
11
|
+
import { execSync } from "node:child_process"
|
|
12
|
+
import * as fs from "node:fs"
|
|
13
|
+
import * as path from "node:path"
|
|
14
|
+
import { fileURLToPath } from "node:url"
|
|
15
|
+
import pkg from "../package.json" with { type: "json" }
|
|
16
|
+
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
18
|
+
const SPECS_DIR = path.join(__dirname, "..", ".specs")
|
|
19
|
+
const VERSION_V3_FILE = path.join(SPECS_DIR, "VERSION_V3")
|
|
20
|
+
const GENERATED_DIR = path.join(__dirname, "..", "src", "generated", "v3")
|
|
21
|
+
|
|
22
|
+
const SPEC_URLS = {
|
|
23
|
+
v3: "https://dac-static.atlassian.com/cloud/jira/platform/swagger-v3.v3.json"
|
|
24
|
+
} as const
|
|
25
|
+
|
|
26
|
+
interface SpecInfo {
|
|
27
|
+
version: string
|
|
28
|
+
title: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const fetchSpecInfo = (url: string): Effect.Effect<SpecInfo, Error> =>
|
|
32
|
+
Effect.tryPromise({
|
|
33
|
+
try: async () => {
|
|
34
|
+
const response = await fetch(url)
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
throw new Error(`Failed to fetch ${url}: ${response.status}`)
|
|
37
|
+
}
|
|
38
|
+
const spec = await response.json() as { info: { version: string; title: string } }
|
|
39
|
+
return { version: spec.info.version, title: spec.info.title }
|
|
40
|
+
},
|
|
41
|
+
catch: (e) => new Error(`Fetch failed: ${e}`)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const fetchAndSaveSpec = (url: string, outputPath: string): Effect.Effect<void, Error> =>
|
|
45
|
+
Effect.tryPromise({
|
|
46
|
+
try: async () => {
|
|
47
|
+
const response = await fetch(url)
|
|
48
|
+
if (!response.ok) {
|
|
49
|
+
throw new Error(`Failed to fetch ${url}: ${response.status}`)
|
|
50
|
+
}
|
|
51
|
+
const spec = await response.text()
|
|
52
|
+
fs.writeFileSync(outputPath, spec, "utf-8")
|
|
53
|
+
},
|
|
54
|
+
catch: (e) => new Error(`Fetch/save failed: ${e}`)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const readVersion = (file: string): Effect.Effect<string | null> =>
|
|
58
|
+
Effect.sync(() => {
|
|
59
|
+
try {
|
|
60
|
+
return fs.readFileSync(file, "utf-8").trim()
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const writeVersion = (file: string, version: string): Effect.Effect<void> =>
|
|
67
|
+
Effect.sync(() => fs.writeFileSync(file, version, "utf-8"))
|
|
68
|
+
|
|
69
|
+
const ensureDir = (dir: string): Effect.Effect<void> =>
|
|
70
|
+
Effect.sync(() => {
|
|
71
|
+
if (!fs.existsSync(dir)) {
|
|
72
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const generateTypes = (specPath: string): Effect.Effect<void, Error> =>
|
|
77
|
+
Effect.tryPromise({
|
|
78
|
+
try: async () => {
|
|
79
|
+
const outputFile = path.join(GENERATED_DIR, "schema.d.ts")
|
|
80
|
+
execSync(
|
|
81
|
+
`pnpm exec openapi-typescript "${specPath}" -o "${outputFile}"`,
|
|
82
|
+
{ cwd: path.join(__dirname, "..") }
|
|
83
|
+
)
|
|
84
|
+
},
|
|
85
|
+
catch: (e) => new Error(`Type generation failed: ${e}`)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const checkOnly = Options.boolean("check").pipe(
|
|
89
|
+
Options.withDescription("Check only, exit 1 if outdated"),
|
|
90
|
+
Options.withDefault(false)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const regenerate = Command.make("regenerate", { checkOnly }, ({ checkOnly }) =>
|
|
94
|
+
Effect.gen(function*() {
|
|
95
|
+
yield* Console.log("Fetching Jira API spec version...")
|
|
96
|
+
|
|
97
|
+
const v3Info = yield* fetchSpecInfo(SPEC_URLS.v3)
|
|
98
|
+
const currentV3 = yield* readVersion(VERSION_V3_FILE)
|
|
99
|
+
|
|
100
|
+
yield* Console.log(`V3: current=${currentV3 ?? "none"}, remote=${v3Info.version}`)
|
|
101
|
+
|
|
102
|
+
const v3Changed = currentV3 !== v3Info.version
|
|
103
|
+
|
|
104
|
+
if (!v3Changed) {
|
|
105
|
+
yield* Console.log("Spec is up to date.")
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (checkOnly) {
|
|
110
|
+
yield* Console.log("Spec is outdated!")
|
|
111
|
+
yield* Console.log(` V3: ${currentV3} -> ${v3Info.version}`)
|
|
112
|
+
return yield* Effect.fail(new Error("Spec outdated"))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
yield* ensureDir(SPECS_DIR)
|
|
116
|
+
yield* ensureDir(GENERATED_DIR)
|
|
117
|
+
|
|
118
|
+
const specPath = path.join(SPECS_DIR, `jira-v3-${v3Info.version}.json`)
|
|
119
|
+
|
|
120
|
+
yield* Console.log(`Fetching V3 spec (${v3Info.version})...`)
|
|
121
|
+
yield* fetchAndSaveSpec(SPEC_URLS.v3, specPath)
|
|
122
|
+
yield* Console.log(`Saved: ${specPath}`)
|
|
123
|
+
|
|
124
|
+
yield* Console.log("Generating types...")
|
|
125
|
+
yield* generateTypes(specPath)
|
|
126
|
+
yield* Console.log(`Generated: src/generated/v3/schema.d.ts`)
|
|
127
|
+
|
|
128
|
+
yield* writeVersion(VERSION_V3_FILE, v3Info.version)
|
|
129
|
+
yield* Console.log("Done!")
|
|
130
|
+
})
|
|
131
|
+
).pipe(Command.withDescription("Regenerate Jira API types from OpenAPI specs"))
|
|
132
|
+
|
|
133
|
+
const cli = Command.run(regenerate, {
|
|
134
|
+
name: pkg.name,
|
|
135
|
+
version: pkg.version
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
Effect.suspend(() => cli(process.argv)).pipe(
|
|
139
|
+
Effect.provide(NodeContext.layer),
|
|
140
|
+
NodeRuntime.runMain
|
|
141
|
+
)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect Layer wrapping openapi-fetch Jira V3 client with auth and base URL.
|
|
3
|
+
*
|
|
4
|
+
* **Mental model**
|
|
5
|
+
*
|
|
6
|
+
* - **Auth-polymorphic**: Supports both Basic (email + API token) and OAuth2 (access token +
|
|
7
|
+
* cloud ID) auth. The layer reads {@link JiraApiConfig} to build the Authorization header
|
|
8
|
+
* and derive the correct base URL.
|
|
9
|
+
* - **openapi-fetch wrapper**: Uses {@link OpenApiFetchClient} for Effect-based methods.
|
|
10
|
+
*
|
|
11
|
+
* **Common tasks**
|
|
12
|
+
*
|
|
13
|
+
* - Use the client: `const jira = yield* JiraApiClient; jira.v3.GET(...)`
|
|
14
|
+
* - Provide the layer: `Effect.provide(JiraApiClient.layer)`
|
|
15
|
+
*
|
|
16
|
+
* @module
|
|
17
|
+
*/
|
|
18
|
+
import * as Context from "effect/Context"
|
|
19
|
+
import * as Effect from "effect/Effect"
|
|
20
|
+
import * as Encoding from "effect/Encoding"
|
|
21
|
+
import * as Layer from "effect/Layer"
|
|
22
|
+
import * as Redacted from "effect/Redacted"
|
|
23
|
+
import type { paths as V3Paths } from "./generated/v3/schema.js"
|
|
24
|
+
import { JiraApiConfig } from "./JiraApiConfig.js"
|
|
25
|
+
import { makeOpenApiFetchClient, type OpenApiFetchClient } from "./OpenApiFetchClient.js"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Jira API client shape (v3 only).
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* import { toEffect } from "@knpkv/jira-api-client"
|
|
33
|
+
*
|
|
34
|
+
* // Get issue by key
|
|
35
|
+
* toEffect(client.v3.client.GET("/rest/api/3/issue/{issueIdOrKey}", {
|
|
36
|
+
* params: { path: { issueIdOrKey: "PROJ-123" } }
|
|
37
|
+
* }))
|
|
38
|
+
*
|
|
39
|
+
* // Search issues with JQL
|
|
40
|
+
* toEffect(client.v3.client.POST("/rest/api/3/search/jql", {
|
|
41
|
+
* body: { jql: "project = PROJ", maxResults: 50 }
|
|
42
|
+
* }))
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* @category Client
|
|
46
|
+
*/
|
|
47
|
+
export interface JiraApiClientShape {
|
|
48
|
+
readonly v3: OpenApiFetchClient<V3Paths>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Jira API client service.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```typescript
|
|
56
|
+
* import { JiraApiClient, JiraApiConfig } from "@knpkv/jira-api-client"
|
|
57
|
+
* import * as Redacted from "effect/Redacted"
|
|
58
|
+
* import * as Effect from "effect/Effect"
|
|
59
|
+
* import * as Layer from "effect/Layer"
|
|
60
|
+
*
|
|
61
|
+
* const program = Effect.gen(function* () {
|
|
62
|
+
* const client = yield* JiraApiClient
|
|
63
|
+
* const issue = yield* toEffect(client.v3.client.GET("/rest/api/3/issue/{issueIdOrKey}", {
|
|
64
|
+
* params: { path: { issueIdOrKey: "PROJ-123" } }
|
|
65
|
+
* }))
|
|
66
|
+
* console.log(issue.fields?.summary)
|
|
67
|
+
* })
|
|
68
|
+
*
|
|
69
|
+
* const configLayer = Layer.succeed(JiraApiConfig, {
|
|
70
|
+
* baseUrl: "https://mysite.atlassian.net",
|
|
71
|
+
* auth: {
|
|
72
|
+
* type: "basic",
|
|
73
|
+
* email: "user@example.com",
|
|
74
|
+
* apiToken: Redacted.make("token")
|
|
75
|
+
* }
|
|
76
|
+
* })
|
|
77
|
+
*
|
|
78
|
+
* Effect.runPromise(
|
|
79
|
+
* program.pipe(
|
|
80
|
+
* Effect.provide(JiraApiClient.layer),
|
|
81
|
+
* Effect.provide(configLayer)
|
|
82
|
+
* )
|
|
83
|
+
* )
|
|
84
|
+
* ```
|
|
85
|
+
*
|
|
86
|
+
* @category Client
|
|
87
|
+
*/
|
|
88
|
+
export class JiraApiClient extends Context.Tag(
|
|
89
|
+
"@knpkv/jira-api-client/JiraApiClient"
|
|
90
|
+
)<JiraApiClient, JiraApiClientShape>() {
|
|
91
|
+
/**
|
|
92
|
+
* Layer that provides JiraApiClient.
|
|
93
|
+
*
|
|
94
|
+
* Requires: JiraApiConfig
|
|
95
|
+
*/
|
|
96
|
+
static readonly layer: Layer.Layer<JiraApiClient, never, JiraApiConfig> = Layer.effect(
|
|
97
|
+
JiraApiClient,
|
|
98
|
+
Effect.gen(function*() {
|
|
99
|
+
const config = yield* JiraApiConfig
|
|
100
|
+
|
|
101
|
+
// Build auth header
|
|
102
|
+
const authHeader = config.auth.type === "basic"
|
|
103
|
+
? `Basic ${Encoding.encodeBase64(`${config.auth.email}:${Redacted.value(config.auth.apiToken)}`)}`
|
|
104
|
+
: `Bearer ${Redacted.value(config.auth.accessToken)}`
|
|
105
|
+
|
|
106
|
+
// Base URL differs by auth type
|
|
107
|
+
const baseUrl = config.auth.type === "oauth2"
|
|
108
|
+
? `https://api.atlassian.com/ex/jira/${config.auth.cloudId}`
|
|
109
|
+
: config.baseUrl
|
|
110
|
+
|
|
111
|
+
const headers = {
|
|
112
|
+
Authorization: authHeader,
|
|
113
|
+
Accept: "application/json",
|
|
114
|
+
"Content-Type": "application/json"
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
v3: makeOpenApiFetchClient<V3Paths>(baseUrl, headers)
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Layer that provides JiraApiClient.
|
|
126
|
+
*
|
|
127
|
+
* @category Layers
|
|
128
|
+
*/
|
|
129
|
+
export const layer = JiraApiClient.layer
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration service tag for the Jira API client (basic or OAuth2 auth).
|
|
3
|
+
*
|
|
4
|
+
* **Mental model**
|
|
5
|
+
*
|
|
6
|
+
* - **Discriminated auth union**: The `auth` field is `{ type: "basic", ... } | { type: "oauth2", ... }`.
|
|
7
|
+
* Basic auth uses email + Redacted API token; OAuth2 uses Redacted access token + cloud ID.
|
|
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.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
import * as Context from "effect/Context"
|
|
14
|
+
import type * as Redacted from "effect/Redacted"
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Configuration shape for Jira API client.
|
|
18
|
+
*
|
|
19
|
+
* @category Config
|
|
20
|
+
*/
|
|
21
|
+
export interface JiraApiConfigShape {
|
|
22
|
+
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 }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Jira API configuration service.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* import { JiraApiConfig } from "@knpkv/jira-api-client"
|
|
34
|
+
* import * as Redacted from "effect/Redacted"
|
|
35
|
+
* import * as Layer from "effect/Layer"
|
|
36
|
+
*
|
|
37
|
+
* const configLayer = Layer.succeed(JiraApiConfig, {
|
|
38
|
+
* baseUrl: "https://mysite.atlassian.net",
|
|
39
|
+
* auth: {
|
|
40
|
+
* type: "basic",
|
|
41
|
+
* email: "user@example.com",
|
|
42
|
+
* apiToken: Redacted.make("token")
|
|
43
|
+
* }
|
|
44
|
+
* })
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* @category Config
|
|
48
|
+
*/
|
|
49
|
+
export class JiraApiConfig extends Context.Tag(
|
|
50
|
+
"@knpkv/jira-api-client/JiraApiConfig"
|
|
51
|
+
)<JiraApiConfig, JiraApiConfigShape>() {}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic Effect wrapper for openapi-fetch clients.
|
|
3
|
+
*
|
|
4
|
+
* Exposes a type-safe `Client<Paths>` and a `toEffect` helper that wraps
|
|
5
|
+
* any `Promise<FetchResponse>` in Effect with error mapping.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* const page = yield* toEffect(client.GET("/pages/{id}", {
|
|
10
|
+
* params: { path: { id: 123 } }
|
|
11
|
+
* }))
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
import * as Data from "effect/Data"
|
|
17
|
+
import * as Effect from "effect/Effect"
|
|
18
|
+
import createClient, { type Client, type FetchResponse } from "openapi-fetch"
|
|
19
|
+
import type { MediaType } from "openapi-typescript-helpers"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Error from openapi-fetch operations.
|
|
23
|
+
*
|
|
24
|
+
* @category Errors
|
|
25
|
+
*/
|
|
26
|
+
export class FetchClientError extends Data.TaggedError("FetchClientError")<{
|
|
27
|
+
readonly error: unknown
|
|
28
|
+
readonly status: number
|
|
29
|
+
readonly message: string
|
|
30
|
+
}> {}
|
|
31
|
+
|
|
32
|
+
/** Extract success `data` from a FetchResponse discriminated union. */
|
|
33
|
+
export type SuccessData<T> = T extends { data: infer D; error?: undefined } ? D
|
|
34
|
+
: T extends { data?: infer D } ? NonNullable<D>
|
|
35
|
+
: never
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wrap an openapi-fetch `Promise<FetchResponse>` in Effect.
|
|
39
|
+
*
|
|
40
|
+
* Extracts `data` on success, maps errors to `FetchClientError`.
|
|
41
|
+
* Fully type-safe — path/body constraints come from the `Client<Paths>` call site.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* const page = yield* toEffect(client.GET("/pages/{id}", { params: { path: { id: 123 } } }))
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @category Utilities
|
|
49
|
+
*/
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- matches FetchResponse generic constraint
|
|
51
|
+
export const toEffect = <T extends Record<string | number, any>, O, M extends MediaType>(
|
|
52
|
+
promise: Promise<FetchResponse<T, O, M>>
|
|
53
|
+
): Effect.Effect<SuccessData<FetchResponse<T, O, M>>, FetchClientError> =>
|
|
54
|
+
Effect.tryPromise({
|
|
55
|
+
try: () =>
|
|
56
|
+
promise.then(({ data, error, response }) => {
|
|
57
|
+
if (error !== undefined || !response.ok) {
|
|
58
|
+
throw { error, status: response.status }
|
|
59
|
+
}
|
|
60
|
+
return data as SuccessData<FetchResponse<T, O, M>>
|
|
61
|
+
}),
|
|
62
|
+
catch: (e) =>
|
|
63
|
+
new FetchClientError({
|
|
64
|
+
error: (e as Record<string, unknown>).error ?? e,
|
|
65
|
+
status: ((e as Record<string, unknown>).status as number | undefined) ?? 0,
|
|
66
|
+
message: typeof (e as Record<string, unknown>).error === "string"
|
|
67
|
+
? (e as Record<string, unknown>).error as string
|
|
68
|
+
: JSON.stringify((e as Record<string, unknown>).error ?? e)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* openapi-fetch client paired with the `toEffect` helper.
|
|
74
|
+
*
|
|
75
|
+
* @category Client
|
|
76
|
+
*/
|
|
77
|
+
export interface OpenApiFetchClient<Paths extends {}> {
|
|
78
|
+
/** Type-safe openapi-fetch client. Use with `toEffect()` to get Effect. */
|
|
79
|
+
readonly client: Client<Paths, "application/json">
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Create an openapi-fetch client with auth headers pre-configured.
|
|
84
|
+
*
|
|
85
|
+
* @category Constructors
|
|
86
|
+
*/
|
|
87
|
+
export const makeOpenApiFetchClient = <Paths extends {}>(
|
|
88
|
+
baseUrl: string,
|
|
89
|
+
headers: Record<string, string>
|
|
90
|
+
): OpenApiFetchClient<Paths> => ({
|
|
91
|
+
client: createClient<Paths, "application/json">({ baseUrl, headers })
|
|
92
|
+
})
|