Skip to content

Commit 39c2524

Browse files
committed
Add Workflow resource
1 parent c099648 commit 39c2524

5 files changed

Lines changed: 405 additions & 49 deletions

File tree

src/client.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { AxiosInstance } from "axios"
1+
import type { AxiosInstance, AxiosPromise } from "axios"
22

33
export interface Client {
44
projectID?: string
@@ -8,4 +8,6 @@ export interface Client {
88
post: AxiosInstance["post"]
99
patch: AxiosInstance["patch"]
1010
delete: AxiosInstance["delete"]
11+
12+
poll: (url: string, timeout: number, every: number) => AxiosPromise
1113
}

src/metafold.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import axios from "axios"
2-
import type { AxiosInstance } from "axios"
2+
import type { AxiosInstance, AxiosPromise, AxiosResponse } from "axios"
33
import type { Client } from "./client.js"
4+
import { PollTimeout } from "./error.js"
45
import { Projects } from "./resources/Projects.js"
56
import { Assets } from "./resources/Assets.js"
67
import { Jobs } from "./resources/Jobs.js"
8+
import { Workflows } from "./resources/Workflows.js"
79

810
const DEFAULT_BASE_URL = "https://api.metafold3d.com"
911

12+
type Timeout = ReturnType<typeof setTimeout>
13+
1014
/** Metafold REST API client. */
1115
class MetafoldClient implements Client {
1216
/**
@@ -27,6 +31,12 @@ class MetafoldClient implements Client {
2731
*/
2832
jobs: Jobs
2933

34+
/**
35+
* Endpoint for managing workflow resources.
36+
* @type {Workflows}
37+
*/
38+
workflows: Workflows
39+
3040
/** Underlying HTTP client. */
3141
axios: AxiosInstance
3242

@@ -87,6 +97,51 @@ class MetafoldClient implements Client {
8797
this.projects = new Projects(this)
8898
this.assets = new Assets(this)
8999
this.jobs = new Jobs(this)
100+
this.workflows = new Workflows(this)
101+
}
102+
103+
/**
104+
* Poll the given URL every one second.
105+
*
106+
* Helpful for waiting on job results given a status URL.
107+
*
108+
* @param {string} url - Workflow status url.
109+
* @param {number} [timeout=12000] - Time in seconds to wait for a result.
110+
* @param {number} [every=1] - Frequency in seconds.
111+
* @returns HTTP response.
112+
*/
113+
poll(url: string, timeout: number = 1000 * 60 * 2, every: number = 1): AxiosPromise {
114+
return new Promise((resolve, reject) => {
115+
/* eslint-disable prefer-const */
116+
let intervalID: Timeout
117+
let timeoutID: Timeout
118+
/* eslint-enable prefer-const */
119+
120+
const clearTimers = () => {
121+
clearInterval(intervalID)
122+
clearTimeout(timeoutID)
123+
}
124+
125+
intervalID = setInterval(() => {
126+
this.get(url)
127+
.then((r: AxiosResponse) => {
128+
if (r.status === 202) {
129+
return
130+
}
131+
clearTimers()
132+
resolve(r)
133+
})
134+
.catch((e) => {
135+
clearTimers()
136+
reject(e)
137+
})
138+
}, 1000 * every)
139+
140+
timeoutID = setTimeout(() => {
141+
clearInterval(intervalID)
142+
reject(new PollTimeout("Polling timed out"))
143+
}, timeout)
144+
})
90145
}
91146
}
92147
export default MetafoldClient

src/resources/Jobs.ts

Lines changed: 19 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@ import type { Client } from "../client.js"
44
import { PollTimeout } from "../error.js"
55
import { constructParams } from "../util.js"
66

7-
type Timeout = ReturnType<typeof setTimeout>
8-
97
export type JobState = "pending" | "started" | "success" | "failure" | "canceled"
108

119
export type IOJSON = {
10+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
1211
params: { [key: string]: any } | null
1312
assets?: { [key: string]: AssetJSON } | null
1413
}
@@ -84,6 +83,7 @@ function mapAsset(a: AssetJSON): Asset {
8483
}
8584
}
8685

86+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
8787
function* iterOwn(m: object): Generator<[string, any]> {
8888
for (const [k, v] of Object.entries(m)) {
8989
if (Object.hasOwn(m, k))
@@ -170,7 +170,7 @@ export class Jobs {
170170
const url = await this.runStatus(type, params, name);
171171
let r = null;
172172
try {
173-
r = await this.poll(url, timeout)
173+
r = await this.client.poll(url, timeout, 1)
174174
} catch (e) {
175175
if (e instanceof PollTimeout) {
176176
throw new Error(
@@ -184,6 +184,22 @@ export class Jobs {
184184
return job(r.data)
185185
}
186186

187+
/**
188+
* Poll the given URL every one second.
189+
*
190+
* Helpful for waiting on job results given a status URL.
191+
*
192+
* @param {string} url - Workflow status url.
193+
* @param {number} [timeout=12000] - Time in seconds to wait for a result.
194+
* @param {number} [every=1] - Frequency in seconds.
195+
* @returns HTTP response.
196+
*
197+
* @deprecated Use Client.poll instead.
198+
*/
199+
poll(url: string, timeout: number = 1000 * 60 * 2, every: number = 1): AxiosPromise {
200+
console.warn("Jobs.poll is deprecated, please use Client.poll instead")
201+
return this.client.poll(url, timeout, every)
202+
}
187203

188204
/**
189205
* Dispatch a new job and return immediately without waiting for result.
@@ -205,50 +221,6 @@ export class Jobs {
205221
return r.data.link;
206222
}
207223

208-
/**
209-
* Poll the given URL every one second.
210-
*
211-
* Helpful for waiting on job results given a status URL.
212-
*
213-
* @param {string} url - Job status url.
214-
* @param {number} [timeout=12000] - Time in seconds to wait for a result.
215-
* @param {number} [every=1] - Frequency in seconds.
216-
* @returns HTTP response.
217-
*/
218-
poll(url: string, timeout: number = 1000 * 60 * 2, every: number = 1): AxiosPromise {
219-
return new Promise((resolve, reject) => {
220-
/* eslint-disable prefer-const */
221-
let intervalID: Timeout
222-
let timeoutID: Timeout
223-
/* eslint-enable prefer-const */
224-
225-
const clearTimers = () => {
226-
clearInterval(intervalID)
227-
clearTimeout(timeoutID)
228-
}
229-
230-
intervalID = setInterval(() => {
231-
this.client.get(url)
232-
.then((r: AxiosResponse) => {
233-
if (r.status === 202) {
234-
return
235-
}
236-
clearTimers()
237-
resolve(r)
238-
})
239-
.catch((e) => {
240-
clearTimers()
241-
reject(e)
242-
})
243-
}, 1000 * every)
244-
245-
timeoutID = setTimeout(() => {
246-
clearInterval(intervalID)
247-
reject(new PollTimeout("Job timed out"))
248-
}, timeout)
249-
})
250-
}
251-
252224
/**
253225
* Update a job.
254226
*

src/resources/Workflows.spec.ts

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { strict as assert } from "assert"
2+
import nock from "nock"
3+
import MetafoldClient from "../metafold.js"
4+
import type { Workflow, WorkflowJSON } from "./Workflows.js"
5+
6+
const defaultDate = new Date("Mon, 01 Jan 2024 00:00:00 GMT")
7+
8+
// Default sort order is descending by id
9+
const workflowList: WorkflowJSON[] = [
10+
{
11+
"id": "3",
12+
"jobs": ["1", "2"],
13+
"state": "success",
14+
"created": "Mon, 01 Jan 2024 00:00:00 GMT",
15+
"started": "Mon, 01 Jan 2024 00:00:00 GMT",
16+
"finished": "Mon, 01 Jan 2024 00:00:00 GMT",
17+
"definition": "...",
18+
"project_id": "1",
19+
},
20+
{
21+
"id": "2",
22+
"jobs": ["1", "2"],
23+
"state": "started",
24+
"created": "Mon, 01 Jan 2024 00:00:00 GMT",
25+
"started": "Mon, 01 Jan 2024 00:00:00 GMT",
26+
"finished": "Mon, 01 Jan 2024 00:00:00 GMT",
27+
"definition": "...",
28+
"project_id": "1",
29+
},
30+
{
31+
"id": "1",
32+
"jobs": ["1", "2"],
33+
"state": "success",
34+
"created": "Mon, 01 Jan 2024 00:00:00 GMT",
35+
"started": "Mon, 01 Jan 2024 00:00:00 GMT",
36+
"finished": "Mon, 01 Jan 2024 00:00:00 GMT",
37+
"definition": "...",
38+
"project_id": "1",
39+
},
40+
]
41+
42+
const newWorkflow: WorkflowJSON = {
43+
id: "1",
44+
jobs: ["1", "2"],
45+
state: "success",
46+
created: "Mon, 01 Jan 2024 00:00:00 GMT",
47+
started: "Mon, 01 Jan 2024 00:00:00 GMT",
48+
finished: "Mon, 01 Jan 2024 00:00:00 GMT",
49+
definition: "foo",
50+
project_id: "1",
51+
}
52+
53+
describe("Workflows", function() {
54+
const metafold = new MetafoldClient("testtoken", "1")
55+
56+
nock("https://api.metafold3d.com", {
57+
reqheaders: { "Authorization": "Bearer testtoken" }
58+
})
59+
.get("/projects/1/workflows")
60+
.reply(200, workflowList)
61+
.get("/projects/1/workflows")
62+
.query({ sort: "id:1" })
63+
.reply(200, workflowList.slice().reverse())
64+
.get("/projects/1/workflows")
65+
.query({ q: "state:started" })
66+
.reply(200, workflowList.slice().filter(
67+
(w: WorkflowJSON) => w.state === "started"))
68+
.get("/projects/1/workflows/1")
69+
.reply(200, workflowList[workflowList.length - 1])
70+
// Workflow success
71+
.post("/projects/1/workflows")
72+
.reply(202, {
73+
...newWorkflow,
74+
link: "https://api.metafold3d.com/projects/1/workflows/1/status",
75+
})
76+
.get("/projects/1/workflows/1/status")
77+
.times(2)
78+
.reply(202, {
79+
...newWorkflow,
80+
state: "started",
81+
})
82+
.get("/projects/1/workflows/1/status")
83+
.reply(201, {
84+
...newWorkflow,
85+
state: "success",
86+
})
87+
// Workflow failure
88+
.post("/projects/1/workflows")
89+
.reply(202, {
90+
...newWorkflow,
91+
link: "https://api.metafold3d.com/projects/1/workflows/1/status",
92+
})
93+
.get("/projects/1/workflows/1/status")
94+
.reply(400, { msg: "Bad request" })
95+
96+
describe("#list()", function() {
97+
it("should retrieve workflow for project 1", async function() {
98+
const workflows = await metafold.workflows.list()
99+
assert.deepEqual(workflows.map((w: Workflow) => w.id), ["3", "2", "1"])
100+
})
101+
it("should retrieve workflow for project 1 sorted by id asc", async function() {
102+
const workflows = await metafold.workflows.list({ sort: "id:1" })
103+
assert.deepEqual(workflows.map((w: Workflow) => w.id), ["1", "2", "3"])
104+
})
105+
it("should retrieve workflow for project 1 with a given state", async function() {
106+
const workflows = await metafold.workflows.list({ q: "state:started" })
107+
assert.ok(
108+
workflows.every((w: Workflow) => w.state === "started"),
109+
"Workflow.state !== state:started")
110+
})
111+
})
112+
113+
describe("#get()", function() {
114+
it("should retrieve workflow 1", async function() {
115+
const workflow = await metafold.workflows.get("1")
116+
assert.deepEqual(workflow, {
117+
id: "1",
118+
jobs: ["1", "2"],
119+
state: "success",
120+
created: defaultDate,
121+
started: defaultDate,
122+
finished: defaultDate,
123+
definition: "...",
124+
project_id: "1",
125+
})
126+
})
127+
})
128+
129+
describe("#run()", function() {
130+
it("should dispatch a workflow and wait for success", async function() {
131+
this.timeout(1000 * 8) // 8 secs
132+
133+
const definition = "foo"
134+
const workflow = await metafold.workflows.run(definition)
135+
assert.deepEqual(workflow, {
136+
id: "1",
137+
jobs: ["1", "2"],
138+
state: "success",
139+
created: defaultDate,
140+
started: defaultDate,
141+
finished: defaultDate,
142+
definition: "foo",
143+
project_id: "1",
144+
})
145+
})
146+
it("should dispatch a workflow and throw an error on failure", async function() {
147+
this.timeout(1000 * 3) // 3 secs
148+
await assert.rejects(
149+
metafold.workflows.run("foo"),
150+
/^Error: Bad request$/,
151+
)
152+
})
153+
it("should dispatch a workflow and throw an error on timeout", async function() {
154+
this.timeout(1000 * 3) // 3 secs
155+
const scope = nock("https://api.metafold3d.com")
156+
.matchHeader("Authorization", "Bearer testtoken")
157+
.post("/projects/1/workflows")
158+
.reply(202, {
159+
...newWorkflow,
160+
link: "https://api.metafold3d.com/projects/1/workflows/1/status",
161+
})
162+
.get("/projects/1/workflows/1/status")
163+
.reply(202, {
164+
...newWorkflow,
165+
state: "started",
166+
})
167+
168+
// Simulate timeout after polling status once.
169+
// FIXME(ryan): Since this is all time-based this test may be a little flakey.
170+
await assert.rejects(
171+
metafold.workflows.run("foo", {}, {}, 2500),
172+
/Workflow failed to complete within 2500 ms/,
173+
)
174+
assert.ok(scope.isDone(), "GET /projects/1/workflows/1/status not called")
175+
})
176+
})
177+
178+
describe("#delete()", function() {
179+
it("should delete workflow 1", async function() {
180+
const scope = nock("https://api.metafold3d.com")
181+
.matchHeader("Authorization", "Bearer testtoken")
182+
.delete("/projects/1/workflows/1")
183+
.reply(200, "OK")
184+
await metafold.workflows.delete("1")
185+
assert.ok(scope.isDone(), "DELETE not called")
186+
})
187+
})
188+
})

0 commit comments

Comments
 (0)