Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@map-colonies/tracing": "^1.0.0",
"@map-colonies/tracing-utils": "^1.0.0",
"@opentelemetry/api": "^1.9.0",
"@turf/helpers": "^7.3.5",
"axios": "^1.15.2",
"compression": "^1.8.0",
"express": "^4.21.2",
Expand Down
51 changes: 51 additions & 0 deletions src/externalClients/layersClient/geometryParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { point, lineString, polygon } from '@turf/helpers';
import type { Geometry, LineString, Point, Polygon, Position } from 'geojson';

export interface RawCoordinate {
latitude: number;
longitude: number;
}

export interface RawGeography {
coordinates: RawCoordinate[];
graphicsObjectKind: { value: string };
}

const MULTI_KINDS = new Set(['MULTIPOINT', 'MULTILINE', 'MULTILINESTRING', 'MULTIPOLYGON']);

function toPosition(c: RawCoordinate): Position {
return [c.longitude, c.latitude];
}

export function geographyToGeoJSON(geography: RawGeography): Geometry {
const kind = geography.graphicsObjectKind.value.toUpperCase();
const coords = geography.coordinates;

if (coords.length === 0) {
throw new Error(`Empty coordinates for graphicsObjectKind=${kind}`);
}

switch (kind) {
case 'POINT':
return point(toPosition(coords[0]!)).geometry as Point;

case 'LINE':
case 'LINESTRING':
if (coords.length < 2) {
throw new Error(`LineString requires >=2 coordinates, got ${coords.length}`);
}
return lineString(coords.map(toPosition)).geometry as LineString;

case 'POLYGON':
if (coords.length < 4) {
throw new Error(`Polygon requires >=4 coordinates (closed ring), got ${coords.length}`);
}
return polygon([coords.map(toPosition)]).geometry as Polygon;

default:
if (MULTI_KINDS.has(kind)) {
throw new Error(`Unsupported graphicsObjectKind=${kind}: MULTI* encoding not yet documented`);
}
throw new Error(`Unknown graphicsObjectKind=${kind}`);
}
}
43 changes: 41 additions & 2 deletions src/externalClients/layersClient/layersClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,29 @@ import type { LayerObject, ThirdPartyResponse } from '../../types';
import { getSyncConfig } from '../../common/syncConfig';
import { SERVICE_NAME } from '../../common/constants';
import { buildLayerQuery } from '../layersClientModel';
import { geographyToGeoJSON, type RawGeography } from './geometryParser';

interface RawLayerObject {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is it not defined in "types/thirdParty"?

createdBy: string | null;
creationTime: string | null;
deleted: boolean;
entityVersion: number | null;
geography: RawGeography & {
height: number | null;
obstacleHeightsRange: { displayName: string } | null;
};
id: string;
identifiers: {
essence: { displayName: string | null; value: string | null } | null;
name: string | null;
number: string | null;
} | null;
lastUpdateTime: string | null;
lastUpdatedBy: string | null;
}

interface GraphQLResponse {
data?: Record<string, LayerObject[]>;
data?: Record<string, RawLayerObject[]>;
extensions?: {
sequence: string;
deletedEntitiesCount: number;
Expand All @@ -18,6 +38,24 @@ interface GraphQLResponse {

const tracer = trace.getTracer(SERVICE_NAME);

function toLayerObject(raw: RawLayerObject): LayerObject {
return {
id: raw.id,
geom: geographyToGeoJSON(raw.geography),
properties: {
createdBy: raw.createdBy,
creationTime: raw.creationTime,
entityVersion: raw.entityVersion,
graphicsObjectKind: raw.geography.graphicsObjectKind,
height: raw.geography.height,
obstacleHeightsRange: raw.geography.obstacleHeightsRange,
identifiers: raw.identifiers,
lastUpdateTime: raw.lastUpdateTime,
lastUpdatedBy: raw.lastUpdatedBy,
},
};
}

export async function fetchPage(layerName: string, sequence: string): Promise<ThirdPartyResponse> {
const config = getSyncConfig();

Expand Down Expand Up @@ -45,7 +83,8 @@ export async function fetchPage(layerName: string, sequence: string): Promise<Th
throw new Error('Malformed response from third-party API');
}

const objects = json.data[layerName] ?? [];
const rawObjects = json.data[layerName] ?? [];
const objects = rawObjects.filter((o) => !o.deleted).map(toLayerObject);
const { sequence: nextSequence, deletedEntitiesCount, fetchedEntitiesCount, deletedEntitiesIds } = json.extensions;

return {
Expand Down
32 changes: 28 additions & 4 deletions src/externalClients/layersClientModel.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,36 @@
// The third-party API uses the layer name itself as the root query field.
// Third-party API uses the layer name as the root query field.
// Pagination/auth inputs are passed via HTTP headers, not GraphQL variables.
// TODO: finalize selection set once the third-party schema is confirmed.
export function buildLayerQuery(layerName: string): string {
return `query {
${layerName} {
createdBy
creationTime
Comment on lines +6 to +7
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to query it?

deleted
entityVersion
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to query it?
Check all fields with the product

geography {
coordinates {
latitude
longitude
}
graphicsObjectKind {
value
}
height
obstacleHeightsRange {
displayName
}
}
id
geom
properties
identifiers {
essence {
displayName
value
}
name
number
}
lastUpdateTime
lastUpdatedBy
}
}`;
}
98 changes: 98 additions & 0 deletions tests/unit/externalClients/geometryParser.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { geographyToGeoJSON, type RawGeography } from '@src/externalClients/layersClient/geometryParser';

function geo(kind: string, coords: { latitude: number; longitude: number }[]): RawGeography {
return { coordinates: coords, graphicsObjectKind: { value: kind } };
}

describe('geographyToGeoJSON', () => {
describe('POINT', () => {
it('maps single coordinate to Point with [lng, lat] order', () => {
const result = geographyToGeoJSON(geo('POINT', [{ latitude: 32.0853, longitude: 34.7818 }]));
expect(result).toEqual({ type: 'Point', coordinates: [34.7818, 32.0853] });
});

it('accepts lowercase kind', () => {
const result = geographyToGeoJSON(geo('point', [{ latitude: 1, longitude: 2 }]));
expect(result).toEqual({ type: 'Point', coordinates: [2, 1] });
});
});

describe('LINE / LINESTRING', () => {
it('maps 2+ coords to LineString in [lng, lat] order', () => {
const result = geographyToGeoJSON(
geo('LINE', [
{ latitude: 32.7453754, longitude: 35.1867382 },
{ latitude: 32.7454701, longitude: 35.1854494 },
])
);
expect(result).toEqual({
type: 'LineString',
coordinates: [
[35.1867382, 32.7453754],
[35.1854494, 32.7454701],
],
});
});

it('accepts LINESTRING alias', () => {
const result = geographyToGeoJSON(
geo('LINESTRING', [
{ latitude: 1, longitude: 2 },
{ latitude: 3, longitude: 4 },
])
);
expect(result.type).toBe('LineString');
});

it('throws when fewer than 2 coords', () => {
expect(() => geographyToGeoJSON(geo('LINE', [{ latitude: 1, longitude: 2 }]))).toThrow(/LineString requires >=2/);
});
});

describe('POLYGON', () => {
it('wraps closed ring in single-element outer array', () => {
const ring = [
{ latitude: 0, longitude: 0 },
{ latitude: 0, longitude: 1 },
{ latitude: 1, longitude: 1 },
{ latitude: 0, longitude: 0 },
];
const result = geographyToGeoJSON(geo('POLYGON', ring));
expect(result).toEqual({
type: 'Polygon',
coordinates: [
[
[0, 0],
[1, 0],
[1, 1],
[0, 0],
],
],
});
});

it('throws when fewer than 4 coords', () => {
const ring = [
{ latitude: 0, longitude: 0 },
{ latitude: 1, longitude: 1 },
{ latitude: 0, longitude: 0 },
];
expect(() => geographyToGeoJSON(geo('POLYGON', ring))).toThrow(/Polygon requires >=4/);
});
});

describe('error cases', () => {
it('throws on empty coordinates', () => {
expect(() => geographyToGeoJSON(geo('POINT', []))).toThrow(/Empty coordinates/);
});

it.each(['MULTIPOINT', 'MULTILINE', 'MULTILINESTRING', 'MULTIPOLYGON'])('throws unsupported for %s', (kind) => {
expect(() => geographyToGeoJSON(geo(kind, [{ latitude: 1, longitude: 2 }]))).toThrow(/Unsupported graphicsObjectKind/);
});

it('throws on unknown kind', () => {
expect(() => geographyToGeoJSON(geo('TRIANGLE', [{ latitude: 1, longitude: 2 }]))).toThrow(/Unknown graphicsObjectKind=TRIANGLE/);
});
});
});
Loading