All files / src/lib nominatim.ts

98.59% Statements 70/71
85% Branches 17/20
100% Functions 2/2
98.59% Lines 70/71

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 1051x                                                 1x 1x 1x 1x 1x 1x 1x   1x   1x 6x 6x 3x 5x 5x 3x 3x 5x     6x 6x 1x 1x 1x 1x 1x   2x 2x   5x 5x 5x 5x 5x 5x 5x 5x 5x 5x   5x 5x 5x   5x 5x 5x 5x 5x 5x 5x 5x 5x   5x 1x 1x 1x 1x 1x 1x   2x 2x 5x 3x 1x 1x 3x 1x 1x 1x 5x 5x 5x 5x  
import { AppError } from "@ontrack/backend-common";
 
export type NominatimConfig = {
  baseUrl: string;
  userAgent: string;
  timeoutMs?: number;
};
 
export type ReverseGeocodeResult = {
  locality: string;
};
 
type NominatimAddress = {
  city?: string;
  town?: string;
  village?: string;
  municipality?: string;
  county?: string;
};
 
type NominatimReverseResponse = {
  display_name?: string;
  address?: NominatimAddress;
};
 
const ADDRESS_KEYS: (keyof NominatimAddress)[] = [
  "city",
  "town",
  "village",
  "municipality",
  "county",
];
 
const DEFAULT_TIMEOUT_MS = 5_000;
 
export function extractLocalityFromNominatimResponse(data: NominatimReverseResponse): string {
  const address = data.address;
  if (address) {
    for (const key of ADDRESS_KEYS) {
      const value = address[key]?.trim();
      if (value) {
        return value;
      }
    }
  }
 
  const displayName = data.display_name?.trim();
  if (displayName) {
    const firstSegment = displayName.split(",")[0]?.trim();
    if (firstSegment) {
      return firstSegment;
    }
  }
 
  return "";
}
 
export async function reverseGeocode(
  lat: number,
  lon: number,
  config: NominatimConfig,
): Promise<ReverseGeocodeResult> {
  const baseUrl = config.baseUrl.replace(/\/$/, "");
  const url = new URL(`${baseUrl}/reverse`);
  url.searchParams.set("lat", String(lat));
  url.searchParams.set("lon", String(lon));
  url.searchParams.set("format", "json");
 
  const controller = new AbortController();
  const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
 
  try {
    const response = await fetch(url, {
      method: "GET",
      headers: {
        "User-Agent": config.userAgent,
        Accept: "application/json",
      },
      signal: controller.signal,
    });
 
    if (!response.ok) {
      throw new AppError(
        502,
        "NOMINATIM_UPSTREAM_ERROR",
        `Nominatim responded with status ${response.status}`,
      );
    }
 
    const data = (await response.json()) as NominatimReverseResponse;
    return { locality: extractLocalityFromNominatimResponse(data) };
  } catch (error) {
    if (error instanceof AppError) {
      throw error;
    }
    if (error instanceof Error && error.name === "AbortError") {
      throw new AppError(502, "NOMINATIM_TIMEOUT", "Nominatim request timed out");
    }
    throw new AppError(502, "NOMINATIM_UNAVAILABLE", "Nominatim request failed");
  } finally {
    clearTimeout(timeout);
  }
}