Skip to content

Commit 338f248

Browse files
committed
feat(api): implement JsonToGeojsonNode for converting JSON to GeoJSON with validation and error handling
1 parent c51b0a0 commit 338f248

2 files changed

Lines changed: 367 additions & 0 deletions

File tree

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
import { describe, expect, it } from "vitest";
2+
import { Node } from "@dafthunk/types";
3+
4+
import { JsonToGeojsonNode } from "./json-to-geojson-node";
5+
import { NodeContext } from "../types";
6+
7+
describe("JsonToGeojsonNode", () => {
8+
const createMockContext = (inputs: Record<string, any>): NodeContext => ({
9+
nodeId: "test-node",
10+
workflowId: "test-workflow",
11+
organizationId: "test-org",
12+
inputs,
13+
env: {} as any,
14+
});
15+
16+
const createNode = (): JsonToGeojsonNode => {
17+
const node: Node = {
18+
id: "test-node",
19+
name: "Test JSON to GeoJSON Node",
20+
type: "json-to-geojson",
21+
position: { x: 0, y: 0 },
22+
inputs: [],
23+
outputs: [],
24+
};
25+
return new JsonToGeojsonNode(node);
26+
};
27+
28+
describe("valid GeoJSON inputs", () => {
29+
it("should convert valid Point geometry", async () => {
30+
const node = createNode();
31+
const point = {
32+
type: "Point",
33+
coordinates: [10, 20],
34+
};
35+
36+
const context = createMockContext({ json: point });
37+
const result = await node.execute(context);
38+
39+
expect(result.status).toBe("completed");
40+
expect(result.outputs?.geojson).toEqual(point);
41+
});
42+
43+
it("should convert valid LineString geometry", async () => {
44+
const node = createNode();
45+
const lineString = {
46+
type: "LineString",
47+
coordinates: [[0, 0], [10, 10], [20, 0]],
48+
};
49+
50+
const context = createMockContext({ json: lineString });
51+
const result = await node.execute(context);
52+
53+
expect(result.status).toBe("completed");
54+
expect(result.outputs?.geojson).toEqual(lineString);
55+
});
56+
57+
it("should convert valid Polygon geometry", async () => {
58+
const node = createNode();
59+
const polygon = {
60+
type: "Polygon",
61+
coordinates: [[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]],
62+
};
63+
64+
const context = createMockContext({ json: polygon });
65+
const result = await node.execute(context);
66+
67+
expect(result.status).toBe("completed");
68+
expect(result.outputs?.geojson).toEqual(polygon);
69+
});
70+
71+
it("should convert valid Feature", async () => {
72+
const node = createNode();
73+
const feature = {
74+
type: "Feature",
75+
geometry: {
76+
type: "Point",
77+
coordinates: [5, 5],
78+
},
79+
properties: {
80+
name: "Test Point",
81+
},
82+
};
83+
84+
const context = createMockContext({ json: feature });
85+
const result = await node.execute(context);
86+
87+
expect(result.status).toBe("completed");
88+
expect(result.outputs?.geojson).toEqual(feature);
89+
});
90+
91+
it("should convert valid FeatureCollection", async () => {
92+
const node = createNode();
93+
const featureCollection = {
94+
type: "FeatureCollection",
95+
features: [
96+
{
97+
type: "Feature",
98+
geometry: {
99+
type: "Point",
100+
coordinates: [5, 5],
101+
},
102+
properties: {
103+
name: "Test Point",
104+
},
105+
},
106+
],
107+
};
108+
109+
const context = createMockContext({ json: featureCollection });
110+
const result = await node.execute(context);
111+
112+
expect(result.status).toBe("completed");
113+
expect(result.outputs?.geojson).toEqual(featureCollection);
114+
});
115+
});
116+
117+
describe("JSON string inputs", () => {
118+
it("should parse and convert JSON string to GeoJSON", async () => {
119+
const node = createNode();
120+
const geojsonString = JSON.stringify({
121+
type: "Point",
122+
coordinates: [10, 20],
123+
});
124+
125+
const context = createMockContext({ json: geojsonString });
126+
const result = await node.execute(context);
127+
128+
expect(result.status).toBe("completed");
129+
expect(result.outputs?.geojson).toEqual({
130+
type: "Point",
131+
coordinates: [10, 20],
132+
});
133+
});
134+
135+
it("should parse and convert Feature JSON string", async () => {
136+
const node = createNode();
137+
const featureString = JSON.stringify({
138+
type: "Feature",
139+
geometry: {
140+
type: "Point",
141+
coordinates: [5, 5],
142+
},
143+
properties: {
144+
fill: "#ff0000",
145+
},
146+
});
147+
148+
const context = createMockContext({ json: featureString });
149+
const result = await node.execute(context);
150+
151+
expect(result.status).toBe("completed");
152+
expect(result.outputs?.geojson).toEqual({
153+
type: "Feature",
154+
geometry: {
155+
type: "Point",
156+
coordinates: [5, 5],
157+
},
158+
properties: {
159+
fill: "#ff0000",
160+
},
161+
});
162+
});
163+
});
164+
165+
describe("error handling", () => {
166+
it("should return error for missing input", async () => {
167+
const node = createNode();
168+
const context = createMockContext({});
169+
170+
const result = await node.execute(context);
171+
172+
expect(result.status).toBe("error");
173+
expect(result.error).toContain("JSON input is required");
174+
});
175+
176+
it("should return error for null input", async () => {
177+
const node = createNode();
178+
const context = createMockContext({ json: null });
179+
180+
const result = await node.execute(context);
181+
182+
expect(result.status).toBe("error");
183+
expect(result.error).toContain("JSON input is required");
184+
});
185+
186+
it("should return error for undefined input", async () => {
187+
const node = createNode();
188+
const context = createMockContext({ json: undefined });
189+
190+
const result = await node.execute(context);
191+
192+
expect(result.status).toBe("error");
193+
expect(result.error).toContain("JSON input is required");
194+
});
195+
196+
it("should return error for invalid JSON string", async () => {
197+
const node = createNode();
198+
const context = createMockContext({ json: "{ invalid json }" });
199+
200+
const result = await node.execute(context);
201+
202+
expect(result.status).toBe("error");
203+
expect(result.error).toContain("Invalid JSON string");
204+
});
205+
206+
it("should return error for unsupported input type", async () => {
207+
const node = createNode();
208+
const context = createMockContext({ json: 123 });
209+
210+
const result = await node.execute(context);
211+
212+
expect(result.status).toBe("error");
213+
expect(result.error).toContain("Unsupported input type");
214+
});
215+
216+
it("should return error for non-object input", async () => {
217+
const node = createNode();
218+
const context = createMockContext({ json: "123" }); // Valid JSON but not an object
219+
220+
const result = await node.execute(context);
221+
222+
expect(result.status).toBe("error");
223+
expect(result.error).toContain("Input must be an object");
224+
});
225+
226+
it("should return error for missing type property", async () => {
227+
const node = createNode();
228+
const context = createMockContext({
229+
json: { coordinates: [10, 20] }
230+
});
231+
232+
const result = await node.execute(context);
233+
234+
expect(result.status).toBe("error");
235+
expect(result.error).toContain("Input must have a 'type' property");
236+
});
237+
238+
it("should return error for invalid GeoJSON type", async () => {
239+
const node = createNode();
240+
const context = createMockContext({
241+
json: { type: "InvalidType", coordinates: [10, 20] }
242+
});
243+
244+
const result = await node.execute(context);
245+
246+
expect(result.status).toBe("error");
247+
expect(result.error).toContain("Invalid GeoJSON format");
248+
});
249+
250+
it("should return error for invalid geometry structure", async () => {
251+
const node = createNode();
252+
const context = createMockContext({
253+
json: { type: "Point", coordinates: "not an array" }
254+
});
255+
256+
const result = await node.execute(context);
257+
258+
expect(result.status).toBe("error");
259+
expect(result.error).toContain("Invalid GeoJSON format");
260+
});
261+
});
262+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { NodeExecution, NodeType, GeoJSON } from "@dafthunk/types";
2+
import { booleanValid, cleanCoords } from "@turf/turf";
3+
4+
import { ExecutableNode, NodeContext } from "../types";
5+
6+
/**
7+
* This node converts JSON data to valid GeoJSON format with validation.
8+
*/
9+
export class JsonToGeojsonNode extends ExecutableNode {
10+
public static readonly nodeType: NodeType = {
11+
id: "json-to-geojson",
12+
name: "JSON to GeoJSON",
13+
type: "json-to-geojson",
14+
description:
15+
"Converts JSON data to valid GeoJSON format with validation.",
16+
tags: ["JSON", "GeoJSON", "Conversion"],
17+
icon: "convert",
18+
inlinable: true,
19+
inputs: [
20+
{
21+
name: "json",
22+
type: "json",
23+
description: "JSON data to convert to GeoJSON.",
24+
required: true,
25+
},
26+
],
27+
outputs: [
28+
{
29+
name: "geojson",
30+
type: "geojson",
31+
description: "The validated and converted GeoJSON.",
32+
},
33+
],
34+
};
35+
36+
public async execute(context: NodeContext): Promise<NodeExecution> {
37+
const { inputs } = context;
38+
39+
try {
40+
const input = inputs.json;
41+
42+
if (input === null || input === undefined) {
43+
return this.createErrorResult("JSON input is required.");
44+
}
45+
46+
let parsedData: any;
47+
48+
// Parse input if it's a string
49+
if (typeof input === "string") {
50+
try {
51+
parsedData = JSON.parse(input);
52+
} catch (parseError) {
53+
return this.createErrorResult(`Invalid JSON string: ${parseError instanceof Error ? parseError.message : String(parseError)}`);
54+
}
55+
} else if (typeof input === "object") {
56+
parsedData = input;
57+
} else {
58+
return this.createErrorResult(`Unsupported input type: ${typeof input}. Expected string or object.`);
59+
}
60+
61+
// Validate and clean the GeoJSON using Turf.js
62+
const geojson = this.validateAndCleanGeoJSON(parsedData);
63+
64+
return this.createSuccessResult({ geojson });
65+
} catch (error) {
66+
return this.createErrorResult(`Failed to convert JSON to GeoJSON: ${error instanceof Error ? error.message : String(error)}`);
67+
}
68+
}
69+
70+
private validateAndCleanGeoJSON(data: any): GeoJSON {
71+
if (!data || typeof data !== "object") {
72+
throw new Error("Input must be an object");
73+
}
74+
75+
if (!data.type) {
76+
throw new Error("Input must have a 'type' property");
77+
}
78+
79+
// Clean the coordinates using Turf.js cleanCoords
80+
const cleaned = cleanCoords(data as any);
81+
82+
// For FeatureCollection, validate each feature individually
83+
if (cleaned.type === "FeatureCollection") {
84+
if (!Array.isArray(cleaned.features)) {
85+
throw new Error("FeatureCollection must have a 'features' array");
86+
}
87+
88+
// Validate each feature in the collection
89+
for (const feature of cleaned.features) {
90+
if (!booleanValid(feature)) {
91+
throw new Error("Invalid GeoJSON format in FeatureCollection");
92+
}
93+
}
94+
95+
return cleaned as GeoJSON;
96+
}
97+
98+
// For other types, use Turf.js booleanValid
99+
if (!booleanValid(cleaned)) {
100+
throw new Error("Invalid GeoJSON format");
101+
}
102+
103+
return cleaned as GeoJSON;
104+
}
105+
}

0 commit comments

Comments
 (0)