@knpkv/clockify-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 +1 -0
- package/.specs/clockify-v1.json +31670 -0
- package/.specs/clockify-v1.patch.json +48 -0
- package/CHANGELOG.md +11 -0
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/ClockifyApiClient.d.ts +67 -0
- package/dist/ClockifyApiClient.d.ts.map +1 -0
- package/dist/ClockifyApiClient.js +141 -0
- package/dist/ClockifyApiClient.js.map +1 -0
- package/dist/ClockifyApiConfig.d.ts +25 -0
- package/dist/ClockifyApiConfig.d.ts.map +1 -0
- package/dist/ClockifyApiConfig.js +16 -0
- package/dist/ClockifyApiConfig.js.map +1 -0
- package/dist/ClockifyApiError.d.ts +11 -0
- package/dist/ClockifyApiError.d.ts.map +1 -0
- package/dist/ClockifyApiError.js +14 -0
- package/dist/ClockifyApiError.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/index.d.ts +2 -0
- package/dist/generated/index.d.ts.map +1 -0
- package/dist/generated/index.js +2 -0
- package/dist/generated/index.js.map +1 -0
- package/dist/generated/schema.d.ts +16204 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/package.json +73 -0
- package/scripts/regenerate.ts +138 -0
- package/src/ClockifyApiClient.ts +278 -0
- package/src/ClockifyApiConfig.ts +25 -0
- package/src/ClockifyApiError.ts +17 -0
- package/src/OpenApiFetchClient.ts +92 -0
- package/src/generated/index.ts +1 -0
- package/src/generated/schema.d.ts +16204 -0
- package/src/index.ts +35 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root barrel export for `@knpkv/clockify-api-client` -- openapi-fetch + Effect Clockify REST client.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
export { ClockifyApiClient, type ClockifyApiClientShape, layer } from "./ClockifyApiClient.js";
|
|
7
|
+
export type { CreateTimeEntryParams, GetTimeEntriesParams, Project, StopTimeEntryParams, Tag, TimeEntry, TimeInterval, UpdateTimeEntryParams, User, Workspace } from "./ClockifyApiClient.js";
|
|
8
|
+
export { ClockifyApiConfig, type ClockifyApiConfigShape } from "./ClockifyApiConfig.js";
|
|
9
|
+
export { ClockifyApiError } from "./ClockifyApiError.js";
|
|
10
|
+
export { FetchClientError, makeOpenApiFetchClient, type OpenApiFetchClient, type SuccessData, toEffect } from "./OpenApiFetchClient.js";
|
|
11
|
+
export type * as V1 from "./generated/index.js";
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,KAAK,EAAE,MAAM,wBAAwB,CAAA;AAE9F,YAAY,EACV,qBAAqB,EACrB,oBAAoB,EACpB,OAAO,EACP,mBAAmB,EACnB,GAAG,EACH,SAAS,EACT,YAAY,EACZ,qBAAqB,EACrB,IAAI,EACJ,SAAS,EACV,MAAM,wBAAwB,CAAA;AAE/B,OAAO,EAAE,iBAAiB,EAAE,KAAK,sBAAsB,EAAE,MAAM,wBAAwB,CAAA;AAEvF,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AAExD,OAAO,EACL,gBAAgB,EAChB,sBAAsB,EACtB,KAAK,kBAAkB,EACvB,KAAK,WAAW,EAChB,QAAQ,EACT,MAAM,yBAAyB,CAAA;AAGhC,YAAY,KAAK,EAAE,MAAM,sBAAsB,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Root barrel export for `@knpkv/clockify-api-client` -- openapi-fetch + Effect Clockify REST client.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
export { ClockifyApiClient, layer } from "./ClockifyApiClient.js";
|
|
7
|
+
export { ClockifyApiConfig } from "./ClockifyApiConfig.js";
|
|
8
|
+
export { ClockifyApiError } from "./ClockifyApiError.js";
|
|
9
|
+
export { FetchClientError, makeOpenApiFetchClient, toEffect } from "./OpenApiFetchClient.js";
|
|
10
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,iBAAiB,EAA+B,KAAK,EAAE,MAAM,wBAAwB,CAAA;AAe9F,OAAO,EAAE,iBAAiB,EAA+B,MAAM,wBAAwB,CAAA;AAEvF,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AAExD,OAAO,EACL,gBAAgB,EAChB,sBAAsB,EAGtB,QAAQ,EACT,MAAM,yBAAyB,CAAA"}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@knpkv/clockify-api-client",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Effect-based Clockify REST API client",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "knpkv",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/knpkv/npm.git",
|
|
10
|
+
"directory": "packages/clockify-api-client"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts"
|
|
20
|
+
},
|
|
21
|
+
"./ClockifyApiClient": {
|
|
22
|
+
"import": "./dist/ClockifyApiClient.js",
|
|
23
|
+
"types": "./dist/ClockifyApiClient.d.ts"
|
|
24
|
+
},
|
|
25
|
+
"./ClockifyApiConfig": {
|
|
26
|
+
"import": "./dist/ClockifyApiConfig.js",
|
|
27
|
+
"types": "./dist/ClockifyApiConfig.d.ts"
|
|
28
|
+
},
|
|
29
|
+
"./ClockifyApiError": {
|
|
30
|
+
"import": "./dist/ClockifyApiError.js",
|
|
31
|
+
"types": "./dist/ClockifyApiError.d.ts"
|
|
32
|
+
},
|
|
33
|
+
"./generated": {
|
|
34
|
+
"import": "./dist/generated/index.js",
|
|
35
|
+
"types": "./dist/generated/index.d.ts"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"effect": "^3.19.3"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"openapi-fetch": "^0.17.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@effect/vitest": "latest",
|
|
49
|
+
"@types/node": "latest",
|
|
50
|
+
"effect": "latest",
|
|
51
|
+
"openapi-typescript": "^7.13.0",
|
|
52
|
+
"openapi-typescript-helpers": "^0.1.0",
|
|
53
|
+
"tsx": "^4.19.4",
|
|
54
|
+
"typescript": "~5.9.0",
|
|
55
|
+
"vitest": "^4.0.13"
|
|
56
|
+
},
|
|
57
|
+
"keywords": [
|
|
58
|
+
"effect",
|
|
59
|
+
"effect-ts",
|
|
60
|
+
"clockify",
|
|
61
|
+
"time-tracking",
|
|
62
|
+
"api-client",
|
|
63
|
+
"openapi"
|
|
64
|
+
],
|
|
65
|
+
"scripts": {
|
|
66
|
+
"build": "tsc && cp src/generated/schema.d.ts dist/generated/schema.d.ts",
|
|
67
|
+
"check": "tsc --noEmit",
|
|
68
|
+
"test": "vitest run",
|
|
69
|
+
"test:watch": "vitest",
|
|
70
|
+
"lint": "eslint src",
|
|
71
|
+
"regenerate": "tsx scripts/regenerate.ts"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Regeneration script for Clockify API client.
|
|
4
|
+
*
|
|
5
|
+
* 1. Fetches latest OpenAPI spec from Clockify docs
|
|
6
|
+
* 2. Applies local patches (.specs/clockify-v1.patch.json) to fix incomplete DTOs
|
|
7
|
+
* 3. Generates TypeScript types via openapi-typescript
|
|
8
|
+
*/
|
|
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
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
17
|
+
const SPECS_DIR = path.join(__dirname, "..", ".specs")
|
|
18
|
+
const SPEC_FILE = path.join(SPECS_DIR, "clockify-v1.json")
|
|
19
|
+
const PATCH_FILE = path.join(SPECS_DIR, "clockify-v1.patch.json")
|
|
20
|
+
const OUTPUT_FILE = path.join(__dirname, "..", "src", "generated", "schema.d.ts")
|
|
21
|
+
const VERSION_FILE = path.join(SPECS_DIR, "VERSION")
|
|
22
|
+
const SPEC_URL = "https://docs.clockify.me/openapi.json"
|
|
23
|
+
|
|
24
|
+
interface SpecInfo {
|
|
25
|
+
version: string
|
|
26
|
+
title: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const fetchSpecInfo = (): Effect.Effect<SpecInfo, Error> =>
|
|
30
|
+
Effect.tryPromise({
|
|
31
|
+
try: async () => {
|
|
32
|
+
const response = await fetch(SPEC_URL)
|
|
33
|
+
if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`)
|
|
34
|
+
const spec = (await response.json()) as { info: { version: string; title: string } }
|
|
35
|
+
return { version: spec.info.version, title: spec.info.title }
|
|
36
|
+
},
|
|
37
|
+
catch: (e) => new Error(`Fetch failed: ${e}`)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const fetchAndSaveSpec = (): Effect.Effect<void, Error> =>
|
|
41
|
+
Effect.tryPromise({
|
|
42
|
+
try: async () => {
|
|
43
|
+
const response = await fetch(SPEC_URL)
|
|
44
|
+
if (!response.ok) throw new Error(`Failed to fetch: ${response.status}`)
|
|
45
|
+
fs.writeFileSync(SPEC_FILE, await response.text(), "utf-8")
|
|
46
|
+
},
|
|
47
|
+
catch: (e) => new Error(`Fetch/save failed: ${e}`)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Apply patches from clockify-v1.patch.json to the spec in-place.
|
|
52
|
+
* Keeps original spec clean — patches add missing fields and required arrays.
|
|
53
|
+
*/
|
|
54
|
+
const applyPatches = (): Effect.Effect<void, Error> =>
|
|
55
|
+
Effect.try({
|
|
56
|
+
try: () => {
|
|
57
|
+
if (!fs.existsSync(PATCH_FILE)) return
|
|
58
|
+
|
|
59
|
+
const spec = JSON.parse(fs.readFileSync(SPEC_FILE, "utf-8"))
|
|
60
|
+
const patches = JSON.parse(fs.readFileSync(PATCH_FILE, "utf-8"))
|
|
61
|
+
const schemas = spec.components?.schemas ?? {}
|
|
62
|
+
|
|
63
|
+
for (const [schemaName, patch] of Object.entries(patches.schemas ?? {}) as Array<[string, Record<string, unknown>]>) {
|
|
64
|
+
const schema = schemas[schemaName]
|
|
65
|
+
if (!schema) continue
|
|
66
|
+
|
|
67
|
+
// Rename properties (e.g. get_id → id)
|
|
68
|
+
if (patch.renameProperties) {
|
|
69
|
+
for (const [from, to] of Object.entries(patch.renameProperties as Record<string, string>)) {
|
|
70
|
+
if (schema.properties?.[from]) {
|
|
71
|
+
schema.properties[to] = schema.properties[from]
|
|
72
|
+
delete schema.properties[from]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Add new properties
|
|
78
|
+
if (patch.addProperties) {
|
|
79
|
+
schema.properties = { ...schema.properties, ...patch.addProperties }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Patch existing properties (overwrite)
|
|
83
|
+
if (patch.patchProperties) {
|
|
84
|
+
for (const [name, value] of Object.entries(patch.patchProperties as Record<string, unknown>)) {
|
|
85
|
+
schema.properties[name] = value
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Set required fields
|
|
90
|
+
if (patch.required) {
|
|
91
|
+
schema.required = patch.required
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fs.writeFileSync(SPEC_FILE, JSON.stringify(spec, null, 2), "utf-8")
|
|
96
|
+
},
|
|
97
|
+
catch: (e) => new Error(`Patch failed: ${e}`)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const generateTypes = (): Effect.Effect<void, Error> =>
|
|
101
|
+
Effect.try({
|
|
102
|
+
try: () => {
|
|
103
|
+
execSync(`npx openapi-typescript "${SPEC_FILE}" -o "${OUTPUT_FILE}"`, {
|
|
104
|
+
cwd: path.join(__dirname, ".."),
|
|
105
|
+
stdio: "inherit"
|
|
106
|
+
})
|
|
107
|
+
},
|
|
108
|
+
catch: (e) => new Error(`Generation failed: ${e}`)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const program = Effect.gen(function*() {
|
|
112
|
+
yield* Console.log("Fetching Clockify API spec version...")
|
|
113
|
+
|
|
114
|
+
const info = yield* fetchSpecInfo()
|
|
115
|
+
const current = fs.existsSync(VERSION_FILE) ? fs.readFileSync(VERSION_FILE, "utf-8").trim() : null
|
|
116
|
+
|
|
117
|
+
yield* Console.log(`Current: ${current ?? "none"}, Remote: ${info.version}`)
|
|
118
|
+
|
|
119
|
+
if (current === info.version) {
|
|
120
|
+
yield* Console.log("Spec up to date, regenerating types...")
|
|
121
|
+
} else {
|
|
122
|
+
if (!fs.existsSync(SPECS_DIR)) fs.mkdirSync(SPECS_DIR, { recursive: true })
|
|
123
|
+
|
|
124
|
+
yield* Console.log(`Fetching spec (${info.version})...`)
|
|
125
|
+
yield* fetchAndSaveSpec()
|
|
126
|
+
fs.writeFileSync(VERSION_FILE, info.version, "utf-8")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
yield* Console.log("Applying patches...")
|
|
130
|
+
yield* applyPatches()
|
|
131
|
+
|
|
132
|
+
yield* Console.log("Generating types...")
|
|
133
|
+
yield* generateTypes()
|
|
134
|
+
|
|
135
|
+
yield* Console.log("Done.")
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
Effect.runPromise(program).catch(console.error)
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect Layer wrapping openapi-fetch Clockify v1 client with auth and base URL.
|
|
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)`
|
|
19
|
+
*
|
|
20
|
+
* @module
|
|
21
|
+
*/
|
|
22
|
+
import * as Context from "effect/Context"
|
|
23
|
+
import * as Effect from "effect/Effect"
|
|
24
|
+
import * as Layer from "effect/Layer"
|
|
25
|
+
import * as Redacted from "effect/Redacted"
|
|
26
|
+
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"]
|
|
48
|
+
|
|
49
|
+
export interface GetTimeEntriesParams {
|
|
50
|
+
readonly start?: string | undefined
|
|
51
|
+
readonly end?: string | undefined
|
|
52
|
+
readonly page?: number | undefined
|
|
53
|
+
readonly pageSize?: number | undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Client shape
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
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>
|
|
68
|
+
readonly createTimeEntry: (
|
|
69
|
+
workspaceId: string,
|
|
70
|
+
params: CreateTimeEntryParams
|
|
71
|
+
) => Effect.Effect<TimeEntry, FetchClientError>
|
|
72
|
+
readonly stopTimer: (
|
|
73
|
+
workspaceId: string,
|
|
74
|
+
userId: string,
|
|
75
|
+
params: StopTimeEntryParams
|
|
76
|
+
) => Effect.Effect<TimeEntry, FetchClientError>
|
|
77
|
+
readonly getTimeEntries: (
|
|
78
|
+
workspaceId: string,
|
|
79
|
+
userId: string,
|
|
80
|
+
params?: GetTimeEntriesParams
|
|
81
|
+
) => Effect.Effect<ReadonlyArray<TimeEntry>, FetchClientError>
|
|
82
|
+
readonly getRunningTimer: (
|
|
83
|
+
workspaceId: string,
|
|
84
|
+
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>
|
|
89
|
+
readonly getTimeEntry: (
|
|
90
|
+
workspaceId: string,
|
|
91
|
+
timeEntryId: string
|
|
92
|
+
) => Effect.Effect<TimeEntry, FetchClientError>
|
|
93
|
+
readonly deleteTimeEntry: (workspaceId: string, timeEntryId: string) => Effect.Effect<void, FetchClientError>
|
|
94
|
+
readonly updateTimeEntry: (
|
|
95
|
+
workspaceId: string,
|
|
96
|
+
timeEntryId: string,
|
|
97
|
+
params: UpdateTimeEntryParams
|
|
98
|
+
) => Effect.Effect<TimeEntry, FetchClientError>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Service tag + layer
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
export class ClockifyApiClient extends Context.Tag(
|
|
106
|
+
"@knpkv/clockify-api-client/ClockifyApiClient"
|
|
107
|
+
)<ClockifyApiClient, ClockifyApiClientShape>() {
|
|
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", {
|
|
135
|
+
params: {
|
|
136
|
+
path: { workspaceId },
|
|
137
|
+
query: { archived: false, "page-size": 500 }
|
|
138
|
+
}
|
|
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
|
+
}
|
|
150
|
+
})
|
|
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
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export const layer = ClockifyApiClient.layer
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration service tag for the Clockify API client.
|
|
3
|
+
*
|
|
4
|
+
* **Mental model**
|
|
5
|
+
*
|
|
6
|
+
* - **Context.Tag for DI**: {@link ClockifyApiConfig} carries API key (as `Redacted`),
|
|
7
|
+
* workspace ID, user ID, and base URL. Provided via `Layer.succeed` by the consumer.
|
|
8
|
+
* - **Redacted API key**: The `apiKey` field uses Effect's `Redacted` type to prevent
|
|
9
|
+
* accidental logging of credentials.
|
|
10
|
+
*
|
|
11
|
+
* @module
|
|
12
|
+
*/
|
|
13
|
+
import * as Context from "effect/Context"
|
|
14
|
+
import type * as Redacted from "effect/Redacted"
|
|
15
|
+
|
|
16
|
+
export interface ClockifyApiConfigShape {
|
|
17
|
+
readonly apiKey: Redacted.Redacted<string>
|
|
18
|
+
readonly workspaceId: string
|
|
19
|
+
readonly userId: string
|
|
20
|
+
readonly baseUrl: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class ClockifyApiConfig extends Context.Tag(
|
|
24
|
+
"@knpkv/clockify-api-client/ClockifyApiConfig"
|
|
25
|
+
)<ClockifyApiConfig, ClockifyApiConfigShape>() {}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tagged error for Clockify API failures.
|
|
3
|
+
*
|
|
4
|
+
* **Mental model**
|
|
5
|
+
*
|
|
6
|
+
* - **Catch by tag**: `Effect.catchTag("ClockifyApiError", ...)` handles all API errors.
|
|
7
|
+
* The `status` field distinguishes HTTP errors (>0) from network errors (0).
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
import * as Data from "effect/Data"
|
|
12
|
+
|
|
13
|
+
export class ClockifyApiError extends Data.TaggedError("ClockifyApiError")<{
|
|
14
|
+
readonly status: number
|
|
15
|
+
readonly message: string
|
|
16
|
+
readonly cause?: unknown
|
|
17
|
+
}> {}
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { paths, components, operations } from "./schema.js"
|