@knpkv/jira-api-client 0.3.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knpkv/jira-api-client",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Effect-based Jira Cloud REST API v3 client",
5
5
  "license": "MIT",
6
6
  "author": "knpkv",
@@ -39,16 +39,16 @@
39
39
  "access": "public"
40
40
  },
41
41
  "peerDependencies": {
42
- "effect": "4.0.0-beta.87"
42
+ "effect": "4.0.0-beta.93"
43
43
  },
44
44
  "devDependencies": {
45
- "@effect/platform-node": "4.0.0-beta.87",
46
- "@effect/vitest": "4.0.0-beta.87",
45
+ "@effect/platform-node": "4.0.0-beta.93",
46
+ "@effect/vitest": "4.0.0-beta.93",
47
47
  "@types/node": "latest",
48
- "effect": "4.0.0-beta.87",
48
+ "effect": "4.0.0-beta.93",
49
49
  "openapi-typescript": "^7.13.0",
50
50
  "openapi-typescript-helpers": "^0.1.0",
51
- "tsx": "^4.22.4",
51
+ "tsx": "^4.23.0",
52
52
  "typescript": "~6.0.3",
53
53
  "vitest": "^4.1.9"
54
54
  },
@@ -4,23 +4,24 @@
4
4
  *
5
5
  * Fetches OpenAPI specs from Atlassian and regenerates openapi-typescript types.
6
6
  */
7
- import { Command, Flag as Options } from "effect/unstable/cli"
8
7
  import { NodeRuntime, NodeServices } from "@effect/platform-node"
9
8
  import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
10
9
  import * as Console from "effect/Console"
10
+ import * as Data from "effect/Data"
11
11
  import * as Effect from "effect/Effect"
12
12
  import * as FileSystem from "effect/FileSystem"
13
13
  import * as Path from "effect/Path"
14
- import * as PlatformError from "effect/PlatformError"
15
- import * as Stdio from "effect/Stdio"
14
+ import type * as PlatformError from "effect/PlatformError"
15
+ import * as Schema from "effect/Schema"
16
+ import { Command, Flag as Options } from "effect/unstable/cli"
16
17
  import { HttpClient } from "effect/unstable/http"
17
18
  import * as ChildProcess from "effect/unstable/process/ChildProcess"
18
19
  import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"
19
20
  import pkg from "../package.json" with { type: "json" }
20
21
 
21
- const SPEC_URLS = {
22
+ const SPEC_URLS: Readonly<Record<"v3", string>> = {
22
23
  v3: "https://dac-static.atlassian.com/cloud/jira/platform/swagger-v3.v3.json"
23
- } as const
24
+ }
24
25
 
25
26
  interface SpecInfo {
26
27
  version: string
@@ -34,30 +35,48 @@ interface ScriptPaths {
34
35
  readonly generatedDir: string
35
36
  }
36
37
 
37
- const fetchText = (url: string): Effect.Effect<string, Error, HttpClient.HttpClient> =>
38
+ class RegenerateError extends Data.TaggedError("RegenerateError")<{
39
+ readonly message: string
40
+ readonly cause?: unknown
41
+ }> {}
42
+
43
+ const JsonString = Schema.fromJsonString(Schema.Json)
44
+
45
+ const SpecInfoResponse = Schema.Struct({
46
+ info: Schema.Struct({
47
+ version: Schema.String,
48
+ title: Schema.String
49
+ })
50
+ })
51
+
52
+ const fetchText = (url: string): Effect.Effect<string, RegenerateError, HttpClient.HttpClient> =>
38
53
  HttpClient.get(url).pipe(
39
54
  Effect.flatMap((response) =>
40
55
  response.status >= 200 && response.status < 300
41
56
  ? response.text
42
- : Effect.fail(new Error(`Failed to fetch ${url}: ${response.status}`))
57
+ : Effect.fail(new RegenerateError({ message: `Failed to fetch ${url}`, cause: { status: response.status } }))
43
58
  ),
44
- Effect.mapError((e) => e instanceof Error ? e : new Error(`Fetch failed: ${e}`))
59
+ Effect.mapError((cause) => new RegenerateError({ message: `Fetch failed: ${url}`, cause }))
45
60
  )
46
61
 
47
- const fetchJson = <A>(url: string): Effect.Effect<A, Error, HttpClient.HttpClient> =>
62
+ const fetchSpecInfo = (url: string): Effect.Effect<SpecInfo, RegenerateError, HttpClient.HttpClient> =>
48
63
  fetchText(url).pipe(
49
- Effect.flatMap((text) =>
50
- Effect.try({
51
- try: () => JSON.parse(text) as A,
52
- catch: (e) => new Error(`JSON parse failed: ${e}`)
53
- })
54
- )
64
+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(SpecInfoResponse))),
65
+ Effect.map(({ info }) => ({ version: info.version, title: info.title })),
66
+ Effect.mapError((cause) => new RegenerateError({ message: "JSON decode failed", cause }))
67
+ )
68
+
69
+ const formatJson = (text: string): Effect.Effect<string, RegenerateError> =>
70
+ Schema.decodeUnknownEffect(JsonString)(text).pipe(
71
+ Effect.flatMap(Schema.encodeEffect(JsonString)),
72
+ Effect.map((encoded) => `${encoded}\n`),
73
+ Effect.mapError((cause) => new RegenerateError({ message: "JSON format failed", cause }))
55
74
  )
56
75
 
57
- const scriptPaths: Effect.Effect<ScriptPaths, Error, Path.Path> = Effect.gen(function*() {
76
+ const scriptPaths: Effect.Effect<ScriptPaths, RegenerateError, Path.Path> = Effect.gen(function*() {
58
77
  const path = yield* Path.Path
59
78
  const scriptFile = yield* path.fromFileUrl(new URL(import.meta.url)).pipe(
60
- Effect.mapError((e) => new Error(`Script path resolution failed: ${e.message}`))
79
+ Effect.mapError((cause) => new RegenerateError({ message: "Script path resolution failed", cause }))
61
80
  )
62
81
  const scriptDir = path.dirname(scriptFile)
63
82
  const packageDir = path.join(scriptDir, "..")
@@ -71,50 +90,52 @@ const scriptPaths: Effect.Effect<ScriptPaths, Error, Path.Path> = Effect.gen(fun
71
90
  }
72
91
  })
73
92
 
74
- const fetchSpecInfo = (url: string): Effect.Effect<SpecInfo, Error, HttpClient.HttpClient> =>
75
- fetchJson<{ info: { version: string; title: string } }>(url).pipe(
76
- Effect.map((spec) => ({ version: spec.info.version, title: spec.info.title }))
77
- )
78
-
79
93
  const fetchAndSaveSpec = (
94
+ packageDir: string,
80
95
  url: string,
81
96
  outputPath: string
82
- ): Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient> =>
97
+ ): Effect.Effect<
98
+ void,
99
+ RegenerateError,
100
+ ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | HttpClient.HttpClient
101
+ > =>
83
102
  Effect.gen(function*() {
84
103
  const fs = yield* FileSystem.FileSystem
85
104
  const spec = yield* fetchText(url)
86
- yield* fs.writeFileString(outputPath, spec).pipe(
87
- Effect.mapError((e) => new Error(`Save failed: ${e.message}`))
105
+ const formattedSpec = yield* formatJson(spec)
106
+ yield* fs.writeFileString(outputPath, formattedSpec).pipe(
107
+ Effect.mapError((cause) => new RegenerateError({ message: "Save failed", cause }))
88
108
  )
109
+ yield* formatFile(packageDir, outputPath)
89
110
  })
90
111
 
91
- const readVersion = (file: string): Effect.Effect<string | null, Error, FileSystem.FileSystem> =>
112
+ const readVersion = (file: string): Effect.Effect<string | null, RegenerateError, FileSystem.FileSystem> =>
92
113
  Effect.gen(function*() {
93
114
  const fs = yield* FileSystem.FileSystem
94
115
  const exists = yield* fs.exists(file).pipe(
95
- Effect.mapError((e) => new Error(`Version lookup failed: ${e.message}`))
116
+ Effect.mapError((cause) => new RegenerateError({ message: "Version lookup failed", cause }))
96
117
  )
97
118
  if (!exists) return null
98
119
 
99
120
  return yield* fs.readFileString(file).pipe(
100
121
  Effect.map((version) => version.trim()),
101
- Effect.mapError((e) => new Error(`Version read failed: ${e.message}`))
122
+ Effect.mapError((cause) => new RegenerateError({ message: "Version read failed", cause }))
102
123
  )
103
124
  })
104
125
 
105
- const writeVersion = (file: string, version: string): Effect.Effect<void, Error, FileSystem.FileSystem> =>
126
+ const writeVersion = (file: string, version: string): Effect.Effect<void, RegenerateError, FileSystem.FileSystem> =>
106
127
  Effect.gen(function*() {
107
128
  const fs = yield* FileSystem.FileSystem
108
129
  yield* fs.writeFileString(file, version).pipe(
109
- Effect.mapError((e) => new Error(`Version write failed: ${e.message}`))
130
+ Effect.mapError((cause) => new RegenerateError({ message: "Version write failed", cause }))
110
131
  )
111
132
  })
112
133
 
113
- const ensureDir = (dir: string): Effect.Effect<void, Error, FileSystem.FileSystem> =>
134
+ const ensureDir = (dir: string): Effect.Effect<void, RegenerateError, FileSystem.FileSystem> =>
114
135
  Effect.gen(function*() {
115
136
  const fs = yield* FileSystem.FileSystem
116
137
  yield* fs.makeDirectory(dir, { recursive: true }).pipe(
117
- Effect.mapError((e) => new Error(`Directory creation failed: ${e.message}`))
138
+ Effect.mapError((cause) => new RegenerateError({ message: "Directory creation failed", cause }))
118
139
  )
119
140
  })
120
141
 
@@ -122,7 +143,7 @@ const generateTypes = (
122
143
  packageDir: string,
123
144
  generatedDir: string,
124
145
  specPath: string
125
- ): Effect.Effect<void, Error, ChildProcessSpawner.ChildProcessSpawner | Path.Path> =>
146
+ ): Effect.Effect<void, RegenerateError, ChildProcessSpawner.ChildProcessSpawner | Path.Path> =>
126
147
  Effect.gen(function*() {
127
148
  const path = yield* Path.Path
128
149
  const outputFile = path.join(generatedDir, "schema.d.ts")
@@ -133,11 +154,11 @@ const generateTypes = (
133
154
  stderr: "inherit"
134
155
  })
135
156
  const exitCode = yield* commandExitCode(command).pipe(
136
- Effect.mapError((e) => new Error(`Type generation failed: ${e.message}`))
157
+ Effect.mapError((cause) => new RegenerateError({ message: "Type generation failed", cause }))
137
158
  )
138
159
 
139
160
  if (exitCode !== 0) {
140
- return yield* Effect.fail(new Error(`Type generation failed with exit code ${exitCode}`))
161
+ return yield* Effect.fail(new RegenerateError({ message: "Type generation failed", cause: { exitCode } }))
141
162
  }
142
163
  })
143
164
 
@@ -148,9 +169,25 @@ const commandExitCode = (
148
169
  Effect.flatMap((spawner) => spawner.exitCode(command))
149
170
  )
150
171
 
151
- const readArgv: Effect.Effect<ReadonlyArray<string>, never, Stdio.Stdio> = Stdio.Stdio.pipe(
152
- Effect.flatMap((stdio) => stdio.args)
153
- )
172
+ const formatFile = (
173
+ packageDir: string,
174
+ file: string
175
+ ): Effect.Effect<void, RegenerateError, ChildProcessSpawner.ChildProcessSpawner> =>
176
+ Effect.gen(function*() {
177
+ const command = ChildProcess.make("pnpm", ["exec", "prettier", "--write", file], {
178
+ cwd: packageDir,
179
+ stdin: "inherit",
180
+ stdout: "inherit",
181
+ stderr: "inherit"
182
+ })
183
+ const exitCode = yield* commandExitCode(command).pipe(
184
+ Effect.mapError((cause) => new RegenerateError({ message: "Formatting failed", cause }))
185
+ )
186
+
187
+ if (exitCode !== 0) {
188
+ return yield* Effect.fail(new RegenerateError({ message: "Formatting failed", cause: { exitCode } }))
189
+ }
190
+ })
154
191
 
155
192
  const checkOnly = Options.boolean("check").pipe(
156
193
  Options.withDescription("Check only, exit 1 if outdated"),
@@ -179,7 +216,7 @@ const regenerate = Command.make("regenerate", { checkOnly }, ({ checkOnly }) =>
179
216
  if (checkOnly) {
180
217
  yield* Console.log("Spec is outdated!")
181
218
  yield* Console.log(` V3: ${currentV3} -> ${v3Info.version}`)
182
- return yield* Effect.fail(new Error("Spec outdated"))
219
+ return yield* Effect.fail(new RegenerateError({ message: "Spec outdated" }))
183
220
  }
184
221
 
185
222
  yield* ensureDir(paths.specsDir)
@@ -188,7 +225,7 @@ const regenerate = Command.make("regenerate", { checkOnly }, ({ checkOnly }) =>
188
225
  const specPath = path.join(paths.specsDir, `jira-v3-${v3Info.version}.json`)
189
226
 
190
227
  yield* Console.log(`Fetching V3 spec (${v3Info.version})...`)
191
- yield* fetchAndSaveSpec(SPEC_URLS.v3, specPath)
228
+ yield* fetchAndSaveSpec(paths.packageDir, SPEC_URLS.v3, specPath)
192
229
  yield* Console.log(`Saved: ${specPath}`)
193
230
 
194
231
  yield* Console.log("Generating types...")
@@ -197,18 +234,14 @@ const regenerate = Command.make("regenerate", { checkOnly }, ({ checkOnly }) =>
197
234
 
198
235
  yield* writeVersion(paths.versionV3File, v3Info.version)
199
236
  yield* Console.log("Done!")
200
- })
201
- ).pipe(Command.withDescription("Regenerate Jira API types from OpenAPI specs"))
237
+ })).pipe(Command.withDescription("Regenerate Jira API types from OpenAPI specs"))
202
238
 
203
239
  const cli = Command.run(regenerate, {
204
240
  name: pkg.name,
205
241
  version: pkg.version
206
242
  })
207
243
 
208
- Effect.gen(function*() {
209
- const argv = yield* readArgv
210
- yield* cli(argv)
211
- }).pipe(
244
+ cli.pipe(
212
245
  Effect.provide(NodeHttpClient.layerFetch),
213
246
  Effect.provide(NodeServices.layer),
214
247
  NodeRuntime.runMain
@@ -110,8 +110,7 @@ export class JiraApiClient extends Context.Service<JiraApiClient, JiraApiClientS
110
110
 
111
111
  const headers = {
112
112
  Authorization: authHeader,
113
- Accept: "application/json",
114
- "Content-Type": "application/json"
113
+ Accept: "application/json"
115
114
  }
116
115
 
117
116
  return {
@@ -31,8 +31,20 @@ export class FetchClientError extends Data.TaggedError("FetchClientError")<{
31
31
 
32
32
  /** Extract success `data` from a FetchResponse discriminated union. */
33
33
  export type SuccessData<T> = T extends { data: infer D; error?: undefined } ? D
34
- : T extends { data?: infer D } ? NonNullable<D>
35
- : never
34
+ : T extends { data?: infer D } ? D | undefined
35
+ : undefined
36
+
37
+ const isRecord = (value: unknown): value is Readonly<Record<PropertyKey, unknown>> =>
38
+ typeof value === "object" && value !== null
39
+
40
+ const errorPayload = (value: unknown): unknown => isRecord(value) && "error" in value ? value.error : value
41
+
42
+ const errorStatus = (value: unknown): number => isRecord(value) && typeof value.status === "number" ? value.status : 0
43
+
44
+ const errorMessage = (value: unknown): string => {
45
+ const payload = errorPayload(value)
46
+ return typeof payload === "string" ? payload : JSON.stringify(payload)
47
+ }
36
48
 
37
49
  /**
38
50
  * Wrap an openapi-fetch `Promise<FetchResponse>` in Effect.
@@ -57,15 +69,13 @@ export const toEffect = <T extends Record<string | number, any>, O, M extends Me
57
69
  if (error !== undefined || !response.ok) {
58
70
  throw { error, status: response.status }
59
71
  }
60
- return data as SuccessData<FetchResponse<T, O, M>>
72
+ return data
61
73
  }),
62
74
  catch: (e) =>
63
75
  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)
76
+ error: errorPayload(e),
77
+ status: errorStatus(e),
78
+ message: errorMessage(e)
69
79
  })
70
80
  })
71
81
 
@@ -76,7 +86,7 @@ export const toEffect = <T extends Record<string | number, any>, O, M extends Me
76
86
  */
77
87
  export interface OpenApiFetchClient<Paths extends {}> {
78
88
  /** Type-safe openapi-fetch client. Use with `toEffect()` to get Effect. */
79
- readonly client: Client<Paths, "application/json">
89
+ readonly client: Client<Paths>
80
90
  }
81
91
 
82
92
  /**
@@ -88,5 +98,5 @@ export const makeOpenApiFetchClient = <Paths extends {}>(
88
98
  baseUrl: string,
89
99
  headers: Record<string, string>
90
100
  ): OpenApiFetchClient<Paths> => ({
91
- client: createClient<Paths, "application/json">({ baseUrl, headers })
101
+ client: createClient<Paths>({ baseUrl, headers })
92
102
  })