@knpkv/clockify-api-client 0.3.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 (43) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +92 -80
  3. package/dist/ClockifyApiClient.d.ts +44 -45
  4. package/dist/ClockifyApiClient.d.ts.map +1 -1
  5. package/dist/ClockifyApiClient.js +52 -121
  6. package/dist/ClockifyApiClient.js.map +1 -1
  7. package/dist/generated/ClockifyApi.d.ts +14432 -0
  8. package/dist/generated/ClockifyApi.d.ts.map +1 -0
  9. package/dist/generated/ClockifyApi.js +1239 -0
  10. package/dist/generated/ClockifyApi.js.map +1 -0
  11. package/dist/index.d.ts +4 -6
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -4
  14. package/dist/index.js.map +1 -1
  15. package/package.json +15 -21
  16. package/scripts/regenerate.ts +182 -201
  17. package/scripts/tsconfig.json +13 -0
  18. package/src/ClockifyApiClient.ts +125 -226
  19. package/src/generated/ClockifyApi.ts +3020 -0
  20. package/src/index.ts +10 -14
  21. package/test/ClockifyApiClient.test.ts +195 -0
  22. package/vitest.config.ts +7 -0
  23. package/.specs/VERSION +0 -1
  24. package/.specs/clockify-v1.json +0 -31670
  25. package/.specs/clockify-v1.patch.json +0 -48
  26. package/dist/ClockifyApiError.d.ts +0 -11
  27. package/dist/ClockifyApiError.d.ts.map +0 -1
  28. package/dist/ClockifyApiError.js +0 -14
  29. package/dist/ClockifyApiError.js.map +0 -1
  30. package/dist/OpenApiFetchClient.d.ts +0 -55
  31. package/dist/OpenApiFetchClient.d.ts.map +0 -1
  32. package/dist/OpenApiFetchClient.js +0 -63
  33. package/dist/OpenApiFetchClient.js.map +0 -1
  34. package/dist/generated/index.d.ts +0 -2
  35. package/dist/generated/index.d.ts.map +0 -1
  36. package/dist/generated/index.js +0 -2
  37. package/dist/generated/index.js.map +0 -1
  38. package/dist/generated/schema.d.ts +0 -16204
  39. package/src/ClockifyApiError.ts +0 -17
  40. package/src/OpenApiFetchClient.ts +0 -92
  41. package/src/generated/index.ts +0 -1
  42. package/src/generated/schema.d.ts +0 -16204
  43. package/tsconfig.tsbuildinfo +0 -1
package/src/index.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  /**
2
- * Root barrel export for `@knpkv/clockify-api-client` -- openapi-fetch + Effect Clockify REST client.
2
+ * Root barrel export for the Schema-validated Effect Clockify client.
3
3
  *
4
4
  * @packageDocumentation
5
5
  */
6
6
 
7
- export { ClockifyApiClient, type ClockifyApiClientShape, layer } from "./ClockifyApiClient.js"
7
+ export {
8
+ ClockifyApiClient,
9
+ type ClockifyApiClientShape,
10
+ type ClockifyClientError,
11
+ layer,
12
+ make
13
+ } from "./ClockifyApiClient.js"
8
14
 
9
15
  export type {
16
+ AuthenticatedClockifyApi,
10
17
  CreateTimeEntryParams,
11
18
  GetTimeEntriesParams,
12
19
  Project,
@@ -21,15 +28,4 @@ export type {
21
28
 
22
29
  export { ClockifyApiConfig, type ClockifyApiConfigShape } from "./ClockifyApiConfig.js"
23
30
 
24
- export { ClockifyApiError } from "./ClockifyApiError.js"
25
-
26
- export {
27
- FetchClientError,
28
- makeOpenApiFetchClient,
29
- type OpenApiFetchClient,
30
- type SuccessData,
31
- toEffect
32
- } from "./OpenApiFetchClient.js"
33
-
34
- // Re-export generated types
35
- export type * as V1 from "./generated/index.js"
31
+ export * as ClockifyApi from "./generated/ClockifyApi.js"
@@ -0,0 +1,195 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import * as Effect from "effect/Effect"
3
+ import * as Layer from "effect/Layer"
4
+ 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 { ClockifyApiClient, ClockifyApiConfig, make } from "../src/index.js"
9
+
10
+ const clientLayer = (
11
+ response: { readonly status: number; readonly body: unknown },
12
+ requests: Array<HttpClientRequest.HttpClientRequest>
13
+ ) =>
14
+ ClockifyApiClient.layer.pipe(
15
+ Layer.provide(Layer.succeed(ClockifyApiConfig, {
16
+ apiKey: Redacted.make("secret"),
17
+ workspaceId: "workspace-1",
18
+ userId: "user-1",
19
+ baseUrl: "https://clockify.test/api"
20
+ })),
21
+ Layer.provide(Layer.succeed(
22
+ HttpClient.HttpClient,
23
+ HttpClient.make((request) =>
24
+ Effect.sync(() => {
25
+ requests.push(request)
26
+ return HttpClientResponse.fromWeb(
27
+ request,
28
+ new Response(JSON.stringify(response.body), {
29
+ status: response.status,
30
+ headers: { "content-type": "application/json" }
31
+ })
32
+ )
33
+ })
34
+ )
35
+ ))
36
+ )
37
+
38
+ describe("ClockifyApiClient", () => {
39
+ it.effect("authenticates requests and decodes responses with Schema", () => {
40
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
41
+ return Effect.gen(function*() {
42
+ const client = yield* ClockifyApiClient
43
+ const user = yield* client.getUser()
44
+ expect(user.id).toBe("user-1")
45
+ expect(user.status).toBe("ACTIVE")
46
+ expect(requests).toHaveLength(1)
47
+ expect(requests[0]?.url).toBe("https://clockify.test/api/v1/user")
48
+ expect(requests[0]?.headers["x-api-key"]).toBe("secret")
49
+ }).pipe(
50
+ Effect.provide(clientLayer({
51
+ status: 200,
52
+ body: { id: "user-1", name: "Ada", email: "ada@example.com", status: "ACTIVE" }
53
+ }, requests))
54
+ )
55
+ })
56
+
57
+ it.effect("decodes nullable interval fields for a running time entry", () => {
58
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
59
+ return Effect.gen(function*() {
60
+ const client = yield* ClockifyApiClient
61
+ const entry = yield* client.getRunningTimer("workspace-1", "user-1")
62
+ expect(entry?.timeInterval?.end).toBeNull()
63
+ expect(entry?.timeInterval?.duration).toBeNull()
64
+ }).pipe(
65
+ Effect.provide(clientLayer({
66
+ status: 200,
67
+ body: [{
68
+ id: "entry-1",
69
+ description: "In progress",
70
+ billable: false,
71
+ userId: "user-1",
72
+ workspaceId: "workspace-1",
73
+ timeInterval: {
74
+ start: "2026-07-11T08:00:00Z",
75
+ end: null,
76
+ duration: null
77
+ }
78
+ }]
79
+ }, requests))
80
+ )
81
+ })
82
+
83
+ it.effect("decodes a created time entry whose optional id references are null", () => {
84
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
85
+ return Effect.gen(function*() {
86
+ const client = yield* ClockifyApiClient
87
+ // Clockify returns kioskId/projectId/taskId as explicit null (not absent) when unset.
88
+ const entry = yield* client.createTimeEntry("workspace-1", {
89
+ description: "[RPS-6169] review transaction mapper",
90
+ start: "2026-07-20T09:00:00Z"
91
+ })
92
+ expect(entry.id).toBe("entry-1")
93
+ expect(entry.kioskId).toBeNull()
94
+ expect(entry.projectId).toBeNull()
95
+ expect(entry.taskId).toBeNull()
96
+ expect(entry.tagIds).toBeNull()
97
+ }).pipe(
98
+ Effect.provide(clientLayer({
99
+ status: 201,
100
+ body: {
101
+ id: "entry-1",
102
+ description: "[RPS-6169] review transaction mapper",
103
+ billable: true,
104
+ userId: "user-1",
105
+ workspaceId: "workspace-1",
106
+ kioskId: null,
107
+ projectId: null,
108
+ taskId: null,
109
+ tagIds: null,
110
+ timeInterval: {
111
+ start: "2026-07-20T09:00:00Z",
112
+ end: null,
113
+ duration: null
114
+ }
115
+ }
116
+ }, requests))
117
+ )
118
+ })
119
+
120
+ it.effect("decodes a time-entries list whose entries have null tagIds", () => {
121
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
122
+ return Effect.gen(function*() {
123
+ const client = yield* ClockifyApiClient
124
+ // `jcf sync reconcile` fetches time entries; Clockify returns tagIds as
125
+ // null (not an empty array) for entries with no tags.
126
+ const entries = yield* client.getTimeEntries("workspace-1", "user-1")
127
+ expect(entries).toHaveLength(1)
128
+ expect(entries[0]?.tagIds).toBeNull()
129
+ }).pipe(
130
+ Effect.provide(clientLayer({
131
+ status: 200,
132
+ body: [{
133
+ id: "entry-1",
134
+ description: "[RPS-6169] review transaction mapper",
135
+ billable: true,
136
+ userId: "user-1",
137
+ workspaceId: "workspace-1",
138
+ tagIds: null,
139
+ timeInterval: {
140
+ start: "2026-07-20T09:00:00Z",
141
+ end: "2026-07-20T11:00:00Z",
142
+ duration: "PT2H"
143
+ }
144
+ }]
145
+ }, requests))
146
+ )
147
+ })
148
+
149
+ it.effect("leaves multipart content type unset so the transport can add its boundary", () => {
150
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
151
+ const httpClient = HttpClient.make((request) =>
152
+ Effect.sync(() => {
153
+ requests.push(request)
154
+ return HttpClientResponse.fromWeb(
155
+ request,
156
+ new Response(JSON.stringify({ name: "avatar.png", url: "https://clockify.test/avatar.png" }), {
157
+ status: 200,
158
+ headers: { "content-type": "application/json" }
159
+ })
160
+ )
161
+ })
162
+ )
163
+ const client = make(httpClient, {
164
+ apiKey: Redacted.make("secret"),
165
+ baseUrl: "https://clockify.test/api"
166
+ })
167
+
168
+ return Effect.gen(function*() {
169
+ yield* client.uploadImage({ file: new Blob(["avatar bytes"], { type: "image/png" }) })
170
+ expect(requests[0]?.headers["content-type"]).toBeUndefined()
171
+ expect(requests[0]?.body._tag).toBe("FormData")
172
+ if (requests[0]?.body._tag !== "FormData") throw new Error("Expected a FormData request body")
173
+ const file = requests[0].body.formData.get("file")
174
+ expect(requests[0].body.formData).toBeInstanceOf(FormData)
175
+ expect(typeof file).not.toBe("string")
176
+ if (file === null || typeof file === "string") throw new Error("Expected a file field")
177
+ expect(yield* Effect.promise(() => file.text())).toBe("avatar bytes")
178
+ expect(requests[0]?.headers["x-api-key"]).toBe("secret")
179
+ })
180
+ })
181
+
182
+ it.effect("fails when a successful response violates the generated schema", () =>
183
+ Effect.gen(function*() {
184
+ const client = yield* ClockifyApiClient
185
+ const result = yield* Effect.result(client.getUser())
186
+ expect(result._tag).toBe("Failure")
187
+ }).pipe(Effect.provide(clientLayer({ status: 200, body: { id: "user-1" } }, []))))
188
+
189
+ it.effect("fails on non-success status codes", () =>
190
+ Effect.gen(function*() {
191
+ const client = yield* ClockifyApiClient
192
+ const result = yield* Effect.result(client.getUser())
193
+ expect(result._tag).toBe("Failure")
194
+ }).pipe(Effect.provide(clientLayer({ status: 401, body: { message: "Unauthorized" } }, []))))
195
+ })
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vitest/config"
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["test/**/*.test.ts"]
6
+ }
7
+ })
package/.specs/VERSION DELETED
@@ -1 +0,0 @@
1
- v1