@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.
- package/CHANGELOG.md +29 -0
- package/README.md +92 -80
- package/dist/ClockifyApiClient.d.ts +44 -45
- package/dist/ClockifyApiClient.d.ts.map +1 -1
- package/dist/ClockifyApiClient.js +52 -121
- package/dist/ClockifyApiClient.js.map +1 -1
- package/dist/generated/ClockifyApi.d.ts +14432 -0
- package/dist/generated/ClockifyApi.d.ts.map +1 -0
- package/dist/generated/ClockifyApi.js +1239 -0
- package/dist/generated/ClockifyApi.js.map +1 -0
- package/dist/index.d.ts +4 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -4
- package/dist/index.js.map +1 -1
- package/package.json +15 -21
- package/scripts/regenerate.ts +182 -201
- package/scripts/tsconfig.json +13 -0
- package/src/ClockifyApiClient.ts +125 -226
- package/src/generated/ClockifyApi.ts +3020 -0
- package/src/index.ts +10 -14
- package/test/ClockifyApiClient.test.ts +195 -0
- package/vitest.config.ts +7 -0
- package/.specs/VERSION +0 -1
- package/.specs/clockify-v1.json +0 -31670
- package/.specs/clockify-v1.patch.json +0 -48
- package/dist/ClockifyApiError.d.ts +0 -11
- package/dist/ClockifyApiError.d.ts.map +0 -1
- package/dist/ClockifyApiError.js +0 -14
- package/dist/ClockifyApiError.js.map +0 -1
- package/dist/OpenApiFetchClient.d.ts +0 -55
- package/dist/OpenApiFetchClient.d.ts.map +0 -1
- package/dist/OpenApiFetchClient.js +0 -63
- package/dist/OpenApiFetchClient.js.map +0 -1
- package/dist/generated/index.d.ts +0 -2
- package/dist/generated/index.d.ts.map +0 -1
- package/dist/generated/index.js +0 -2
- package/dist/generated/index.js.map +0 -1
- package/dist/generated/schema.d.ts +0 -16204
- package/src/ClockifyApiError.ts +0 -17
- package/src/OpenApiFetchClient.ts +0 -92
- package/src/generated/index.ts +0 -1
- package/src/generated/schema.d.ts +0 -16204
- package/tsconfig.tsbuildinfo +0 -1
package/src/index.ts
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Root barrel export for
|
|
2
|
+
* Root barrel export for the Schema-validated Effect Clockify client.
|
|
3
3
|
*
|
|
4
4
|
* @packageDocumentation
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
export {
|
|
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
|
|
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
|
+
})
|
package/vitest.config.ts
ADDED
package/.specs/VERSION
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
v1
|