@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
@@ -1,50 +1,40 @@
1
1
  /**
2
- * Effect Layer wrapping openapi-fetch Clockify v1 client with auth and base URL.
2
+ * Schema-validated Clockify API client.
3
3
  *
4
- * **Mental model**
5
- *
6
- * - **Auth via X-Api-Key**: The layer reads {@link ClockifyApiConfig} to build the
7
- * `X-Api-Key` header and derive the base URL.
8
- * - **openapi-fetch wrapper**: Uses {@link OpenApiFetchClient} for type-safe HTTP calls.
9
- * - **Method-based interface**: Exposes convenience methods (getUser, createTimeEntry, etc.)
10
- * for backwards compatibility with jira-clockify consumers.
11
- * - **Raw API access**: `.api` exposes the raw `OpenApiFetchClient<paths>` for direct access.
12
- *
13
- * **Common tasks**
14
- *
15
- * - Use the client: `const clockify = yield* ClockifyApiClient`
16
- * - Call a method: `clockify.getProjects(workspaceId)`
17
- * - Raw access: `toEffect(clockify.api.client.GET("/v1/user"))`
18
- * - Provide the layer: `Effect.provide(ClockifyApiClient.layer)`
4
+ * The OpenAPI generator owns request construction and response decoding. This
5
+ * module only applies Clockify authentication and provides domain conveniences
6
+ * used by the timer application.
19
7
  *
20
8
  * @module
21
9
  */
22
10
  import * as Context from "effect/Context"
23
11
  import * as Effect from "effect/Effect"
12
+ import { flow } from "effect/Function"
24
13
  import * as Layer from "effect/Layer"
25
14
  import * as Redacted from "effect/Redacted"
15
+ import type { SchemaError } from "effect/Schema"
16
+ import * as HttpClient from "effect/unstable/http/HttpClient"
17
+ import type * as HttpClientError from "effect/unstable/http/HttpClientError"
18
+ import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
19
+ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
26
20
  import { ClockifyApiConfig } from "./ClockifyApiConfig.js"
27
- import type { components, paths } from "./generated/schema.js"
28
- import {
29
- type FetchClientError,
30
- makeOpenApiFetchClient,
31
- type OpenApiFetchClient,
32
- toEffect
33
- } from "./OpenApiFetchClient.js"
34
-
35
- // ---------------------------------------------------------------------------
36
- // Domain types — derived from patched OpenAPI spec schema
37
- // ---------------------------------------------------------------------------
38
-
39
- export type User = components["schemas"]["UserDto"]
40
- export type Workspace = components["schemas"]["WorkspaceDtoV1"]
41
- export type Project = components["schemas"]["ProjectDtoV1"]
42
- export type Tag = components["schemas"]["TagDto"]
43
- export type TimeInterval = components["schemas"]["TimeIntervalDto"]
44
- export type TimeEntry = components["schemas"]["TimeEntryDto"]
45
- export type CreateTimeEntryParams = components["schemas"]["CreateTimeEntryRequest"]
46
- export type StopTimeEntryParams = components["schemas"]["StopTimeEntryRequest"]
47
- export type UpdateTimeEntryParams = components["schemas"]["UpdateTimeEntryRequest"]
21
+ import * as Generated from "./generated/ClockifyApi.js"
22
+
23
+ export type ClockifyClientError = HttpClientError.HttpClientError | SchemaError
24
+
25
+ export type User = Generated.GetLoggedUser200
26
+ export type Workspace = Generated.GetWorkspacesOfUser200[number]
27
+ export type Project = Generated.GetProjects200[number]
28
+ export type Tag = Generated.GetTags200[number]
29
+ export type TimeEntry = Generated.GetTimeEntries200[number] | Generated.CreateTimeEntry201
30
+ export type TimeInterval = NonNullable<TimeEntry["timeInterval"]>
31
+ export type CreateTimeEntryParams = Generated.CreateTimeEntryRequestJson
32
+ export type StopTimeEntryParams = Generated.StopRunningTimeEntryRequestJson
33
+ export type UpdateTimeEntryParams = Generated.UpdateTimeEntryRequestJson
34
+
35
+ export type AuthenticatedClockifyApi = Omit<Generated.ClockifyApi, "uploadImage"> & {
36
+ readonly uploadImage: (input: { readonly file: Blob }) => Effect.Effect<Generated.UploadImage200, ClockifyClientError>
37
+ }
48
38
 
49
39
  export interface GetTimeEntriesParams {
50
40
  readonly start?: string | undefined
@@ -53,226 +43,135 @@ export interface GetTimeEntriesParams {
53
43
  readonly pageSize?: number | undefined
54
44
  }
55
45
 
56
- // ---------------------------------------------------------------------------
57
- // Client shape
58
- // ---------------------------------------------------------------------------
59
-
60
46
  export interface ClockifyApiClientShape {
61
- /** Raw openapi-fetch client for direct type-safe API access. */
62
- readonly api: OpenApiFetchClient<paths>
63
-
64
- readonly getUser: () => Effect.Effect<User, FetchClientError>
65
- readonly getWorkspaces: () => Effect.Effect<ReadonlyArray<Workspace>, FetchClientError>
66
- readonly getProjects: (workspaceId: string) => Effect.Effect<ReadonlyArray<Project>, FetchClientError>
67
- readonly getProjectByName: (workspaceId: string, name: string) => Effect.Effect<Project | null, FetchClientError>
47
+ readonly getUser: () => Effect.Effect<User, ClockifyClientError>
48
+ readonly getWorkspaces: () => Effect.Effect<ReadonlyArray<Workspace>, ClockifyClientError>
49
+ readonly getProjects: (workspaceId: string) => Effect.Effect<ReadonlyArray<Project>, ClockifyClientError>
50
+ readonly getProjectByName: (workspaceId: string, name: string) => Effect.Effect<Project | null, ClockifyClientError>
68
51
  readonly createTimeEntry: (
69
52
  workspaceId: string,
70
53
  params: CreateTimeEntryParams
71
- ) => Effect.Effect<TimeEntry, FetchClientError>
54
+ ) => Effect.Effect<Generated.CreateTimeEntry201, ClockifyClientError>
72
55
  readonly stopTimer: (
73
56
  workspaceId: string,
74
57
  userId: string,
75
58
  params: StopTimeEntryParams
76
- ) => Effect.Effect<TimeEntry, FetchClientError>
59
+ ) => Effect.Effect<Generated.StopRunningTimeEntry200, ClockifyClientError>
77
60
  readonly getTimeEntries: (
78
61
  workspaceId: string,
79
62
  userId: string,
80
63
  params?: GetTimeEntriesParams
81
- ) => Effect.Effect<ReadonlyArray<TimeEntry>, FetchClientError>
64
+ ) => Effect.Effect<Generated.GetTimeEntries200, ClockifyClientError>
82
65
  readonly getRunningTimer: (
83
66
  workspaceId: string,
84
67
  userId: string
85
- ) => Effect.Effect<TimeEntry | null, FetchClientError>
86
- readonly getTags: (workspaceId: string) => Effect.Effect<ReadonlyArray<Tag>, FetchClientError>
87
- readonly createTag: (workspaceId: string, name: string) => Effect.Effect<Tag, FetchClientError>
88
- readonly findOrCreateTag: (workspaceId: string, name: string) => Effect.Effect<Tag, FetchClientError>
68
+ ) => Effect.Effect<Generated.GetTimeEntries200[number] | null, ClockifyClientError>
69
+ readonly getTags: (workspaceId: string) => Effect.Effect<Generated.GetTags200, ClockifyClientError>
70
+ readonly createTag: (
71
+ workspaceId: string,
72
+ name: string
73
+ ) => Effect.Effect<Generated.CreateNewTag201, ClockifyClientError>
74
+ readonly findOrCreateTag: (
75
+ workspaceId: string,
76
+ name: string
77
+ ) => Effect.Effect<Generated.GetTags200[number] | Generated.CreateNewTag201, ClockifyClientError>
89
78
  readonly getTimeEntry: (
90
79
  workspaceId: string,
91
80
  timeEntryId: string
92
- ) => Effect.Effect<TimeEntry, FetchClientError>
93
- readonly deleteTimeEntry: (workspaceId: string, timeEntryId: string) => Effect.Effect<void, FetchClientError>
81
+ ) => Effect.Effect<Generated.GetTimeEntry200, ClockifyClientError>
82
+ readonly deleteTimeEntry: (workspaceId: string, timeEntryId: string) => Effect.Effect<void, ClockifyClientError>
94
83
  readonly updateTimeEntry: (
95
84
  workspaceId: string,
96
85
  timeEntryId: string,
97
86
  params: UpdateTimeEntryParams
98
- ) => Effect.Effect<TimeEntry, FetchClientError>
87
+ ) => Effect.Effect<Generated.UpdateTimeEntry200, ClockifyClientError>
99
88
  }
100
89
 
101
- // ---------------------------------------------------------------------------
102
- // Service tag + layer
103
- // ---------------------------------------------------------------------------
90
+ export const make = (
91
+ httpClient: HttpClient.HttpClient,
92
+ options: { readonly apiKey: Redacted.Redacted<string>; readonly baseUrl: string }
93
+ ): AuthenticatedClockifyApi => {
94
+ const api = Generated.make(httpClient.pipe(
95
+ HttpClient.mapRequest(flow(
96
+ HttpClientRequest.prependUrl(options.baseUrl),
97
+ HttpClientRequest.setHeader("X-Api-Key", Redacted.value(options.apiKey))
98
+ ))
99
+ ))
100
+ return {
101
+ ...api,
102
+ uploadImage: (input) => {
103
+ const form = new FormData()
104
+ form.append("file", input.file)
105
+ return api.httpClient.execute(
106
+ HttpClientRequest.post("/v1/file/image").pipe(HttpClientRequest.bodyFormData(form))
107
+ ).pipe(
108
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
109
+ Effect.flatMap(HttpClientResponse.schemaBodyJson(Generated.UploadImage200))
110
+ )
111
+ }
112
+ }
113
+ }
104
114
 
105
115
  export class ClockifyApiClient extends Context.Service<ClockifyApiClient, ClockifyApiClientShape>()(
106
116
  "@knpkv/clockify-api-client/ClockifyApiClient"
107
117
  ) {
108
- static readonly layer: Layer.Layer<ClockifyApiClient, never, ClockifyApiConfig> = Layer.effect(
109
- ClockifyApiClient,
110
- Effect.gen(function*() {
111
- const config = yield* ClockifyApiConfig
112
-
113
- const headers = {
114
- "X-Api-Key": Redacted.value(config.apiKey),
115
- "Content-Type": "application/json"
116
- }
117
-
118
- const api = makeOpenApiFetchClient<paths>(config.baseUrl, headers)
119
- const { client } = api
120
-
121
- return {
122
- api,
123
-
124
- getUser: () => toEffect(client.GET("/v1/user")) as Effect.Effect<User, FetchClientError>,
125
-
126
- getWorkspaces: () =>
127
- toEffect(client.GET("/v1/workspaces")) as unknown as Effect.Effect<
128
- ReadonlyArray<Workspace>,
129
- FetchClientError
130
- >,
131
-
132
- getProjects: (workspaceId) =>
133
- toEffect(
134
- client.GET("/v1/workspaces/{workspaceId}/projects", {
118
+ static readonly layer: Layer.Layer<ClockifyApiClient, never, ClockifyApiConfig | HttpClient.HttpClient> = Layer
119
+ .effect(
120
+ ClockifyApiClient,
121
+ Effect.gen(function*() {
122
+ const config = yield* ClockifyApiConfig
123
+ const httpClient = yield* HttpClient.HttpClient
124
+ const api = make(httpClient, config)
125
+
126
+ const getProjects = (workspaceId: string) =>
127
+ api.getProjects(workspaceId, { params: { archived: false, "page-size": 500 } })
128
+
129
+ const getTags = (workspaceId: string) =>
130
+ api.getTags(workspaceId, { params: { archived: false, "page-size": 200 } })
131
+
132
+ return ClockifyApiClient.of({
133
+ getUser: () => api.getLoggedUser(undefined),
134
+ getWorkspaces: () => api.getWorkspacesOfUser(undefined),
135
+ getProjects,
136
+ getProjectByName: (workspaceId, name) =>
137
+ api.getProjects(workspaceId, { params: { name, archived: false } }).pipe(
138
+ Effect.map((projects) =>
139
+ projects.find((project) => project.name.toLowerCase() === name.toLowerCase()) ?? null
140
+ )
141
+ ),
142
+ createTimeEntry: (workspaceId, payload) => api.createTimeEntry(workspaceId, { payload }),
143
+ stopTimer: (workspaceId, userId, payload) => api.stopRunningTimeEntry(workspaceId, userId, { payload }),
144
+ getTimeEntries: (workspaceId, userId, params) =>
145
+ api.getTimeEntries(workspaceId, userId, {
135
146
  params: {
136
- path: { workspaceId },
137
- query: { archived: false, "page-size": 500 }
147
+ ...(params?.start !== undefined ? { start: params.start } : {}),
148
+ ...(params?.end !== undefined ? { end: params.end } : {}),
149
+ ...(params?.page !== undefined ? { page: params.page } : {}),
150
+ ...(params?.pageSize !== undefined ? { "page-size": params.pageSize } : {})
138
151
  }
139
- })
140
- ) as Effect.Effect<ReadonlyArray<Project>, FetchClientError>,
141
-
142
- getProjectByName: (workspaceId, name) =>
143
- Effect.gen(function*() {
144
- const projects = yield* toEffect(
145
- client.GET("/v1/workspaces/{workspaceId}/projects", {
146
- params: {
147
- path: { workspaceId },
148
- query: { name, archived: false }
149
- }
152
+ }),
153
+ getRunningTimer: (workspaceId, userId) =>
154
+ api.getTimeEntries(workspaceId, userId, {
155
+ params: { "in-progress": "true", "page-size": 1 }
156
+ }).pipe(Effect.map((entries) => entries[0] ?? null)),
157
+ getTags,
158
+ createTag: (workspaceId, name) => api.createNewTag(workspaceId, { payload: { name } }),
159
+ findOrCreateTag: (workspaceId, name) =>
160
+ api.getTags(workspaceId, { params: { name, archived: false } }).pipe(
161
+ Effect.flatMap((tags) => {
162
+ const existing = tags.find((tag) => tag.name?.toLowerCase() === name.toLowerCase())
163
+ return existing === undefined
164
+ ? api.createNewTag(workspaceId, { payload: { name } })
165
+ : Effect.succeed(existing)
150
166
  })
151
- ) as Effect.Effect<ReadonlyArray<Project>, FetchClientError>
152
- return projects.find((p) => p.name.toLowerCase() === name.toLowerCase()) ?? null
153
- }),
154
-
155
- createTimeEntry: (workspaceId, params) =>
156
- toEffect(
157
- client.POST("/v1/workspaces/{workspaceId}/time-entries", {
158
- params: { path: { workspaceId } },
159
- body: {
160
- description: params.description,
161
- start: params.start,
162
- ...(params.end ? { end: params.end } : {}),
163
- ...(params.projectId ? { projectId: params.projectId } : {}),
164
- ...(params.taskId ? { taskId: params.taskId } : {}),
165
- ...(params.billable !== undefined ? { billable: params.billable } : {}),
166
- ...(params.tagIds !== undefined ? { tagIds: [...params.tagIds] } : {})
167
- }
168
- })
169
- ) as Effect.Effect<TimeEntry, FetchClientError>,
170
-
171
- stopTimer: (workspaceId, userId, params) =>
172
- toEffect(
173
- client.PATCH("/v1/workspaces/{workspaceId}/user/{userId}/time-entries", {
174
- params: { path: { workspaceId, userId } },
175
- body: { end: params.end }
176
- })
177
- ) as Effect.Effect<TimeEntry, FetchClientError>,
178
-
179
- getTimeEntries: (workspaceId, userId, params) =>
180
- toEffect(
181
- client.GET("/v1/workspaces/{workspaceId}/user/{userId}/time-entries", {
182
- params: {
183
- path: { workspaceId, userId },
184
- query: {
185
- ...(params?.start !== undefined ? { start: params.start } : {}),
186
- ...(params?.end !== undefined ? { end: params.end } : {}),
187
- ...(params?.page !== undefined ? { page: params.page } : {}),
188
- ...(params?.pageSize !== undefined ? { "page-size": params.pageSize } : {})
189
- }
190
- }
191
- })
192
- ) as Effect.Effect<ReadonlyArray<TimeEntry>, FetchClientError>,
193
-
194
- getRunningTimer: (workspaceId, userId) =>
195
- Effect.gen(function*() {
196
- const entries = yield* toEffect(
197
- client.GET("/v1/workspaces/{workspaceId}/user/{userId}/time-entries", {
198
- params: {
199
- path: { workspaceId, userId },
200
- query: { "in-progress": true, "page-size": 1 }
201
- }
202
- })
203
- ) as Effect.Effect<ReadonlyArray<TimeEntry>, FetchClientError>
204
- return entries.length > 0 ? entries[0]! : null
205
- }),
206
-
207
- getTimeEntry: (workspaceId, timeEntryId) =>
208
- toEffect(
209
- client.GET("/v1/workspaces/{workspaceId}/time-entries/{id}", {
210
- params: { path: { workspaceId, id: timeEntryId } }
211
- })
212
- ) as Effect.Effect<TimeEntry, FetchClientError>,
213
-
214
- updateTimeEntry: (workspaceId, timeEntryId, params) =>
215
- toEffect(
216
- client.PUT("/v1/workspaces/{workspaceId}/time-entries/{id}", {
217
- params: { path: { workspaceId, id: timeEntryId } },
218
- body: {
219
- start: params.start,
220
- ...(params.end !== undefined ? { end: params.end } : {}),
221
- ...(params.description !== undefined ? { description: params.description } : {}),
222
- ...(params.projectId !== undefined ? { projectId: params.projectId } : {}),
223
- ...(params.billable !== undefined ? { billable: params.billable } : {}),
224
- ...(params.tagIds !== undefined ? { tagIds: [...params.tagIds] } : {})
225
- }
226
- })
227
- ) as Effect.Effect<TimeEntry, FetchClientError>,
228
-
229
- getTags: (workspaceId) =>
230
- toEffect(
231
- client.GET("/v1/workspaces/{workspaceId}/tags", {
232
- params: {
233
- path: { workspaceId },
234
- query: { archived: false, "page-size": 200 }
235
- }
236
- })
237
- ) as Effect.Effect<ReadonlyArray<Tag>, FetchClientError>,
238
-
239
- createTag: (workspaceId, name) =>
240
- toEffect(
241
- client.POST("/v1/workspaces/{workspaceId}/tags", {
242
- params: { path: { workspaceId } },
243
- body: { name }
244
- })
245
- ) as Effect.Effect<Tag, FetchClientError>,
246
-
247
- findOrCreateTag: (workspaceId, name) =>
248
- Effect.gen(function*() {
249
- const tags = yield* toEffect(
250
- client.GET("/v1/workspaces/{workspaceId}/tags", {
251
- params: {
252
- path: { workspaceId },
253
- query: { name, archived: false }
254
- }
255
- })
256
- ) as Effect.Effect<ReadonlyArray<Tag>, FetchClientError>
257
- const existing = tags.find((t) => t.name.toLowerCase() === name.toLowerCase())
258
- if (existing) return existing
259
- return yield* toEffect(
260
- client.POST("/v1/workspaces/{workspaceId}/tags", {
261
- params: { path: { workspaceId } },
262
- body: { name }
263
- })
264
- ) as Effect.Effect<Tag, FetchClientError>
265
- }),
266
-
267
- deleteTimeEntry: (workspaceId, timeEntryId) =>
268
- toEffect(
269
- client.DELETE("/v1/workspaces/{workspaceId}/time-entries/{id}", {
270
- params: { path: { workspaceId, id: timeEntryId } }
271
- })
272
- ) as Effect.Effect<void, FetchClientError>
273
- }
274
- })
275
- )
167
+ ),
168
+ getTimeEntry: (workspaceId, timeEntryId) => api.getTimeEntry(workspaceId, timeEntryId, undefined),
169
+ deleteTimeEntry: (workspaceId, timeEntryId) => api.deleteTimeEntry(workspaceId, timeEntryId, undefined),
170
+ updateTimeEntry: (workspaceId, timeEntryId, payload) =>
171
+ api.updateTimeEntry(workspaceId, timeEntryId, { payload })
172
+ })
173
+ })
174
+ )
276
175
  }
277
176
 
278
177
  export const layer = ClockifyApiClient.layer