astrospatial-services 0.0.0-stage → 0.1.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.
@@ -0,0 +1,198 @@
1
+ import { FetchHttpTransport, } from "../../core/HttpTransport.js";
2
+ import { ServiceError } from "../../core/ServiceError.js";
3
+ function tapBaseUrl(endpoint) {
4
+ let url;
5
+ try {
6
+ url = new URL(endpoint);
7
+ }
8
+ catch {
9
+ throw new TypeError("Invalid TAP endpoint URL");
10
+ }
11
+ if (!["http:", "https:"].includes(url.protocol) ||
12
+ url.username ||
13
+ url.password ||
14
+ url.search ||
15
+ url.hash) {
16
+ throw new TypeError("TAP endpoint must be an HTTP(S) URL without credentials, query or fragment");
17
+ }
18
+ url.pathname = url.pathname.replace(/\/+$/, "");
19
+ if (url.pathname.endsWith("/sync"))
20
+ url.pathname = url.pathname.slice(0, -5);
21
+ if (url.pathname.endsWith("/capabilities"))
22
+ url.pathname = url.pathname.slice(0, -13);
23
+ return url.toString().replace(/\/$/, "");
24
+ }
25
+ /** Parse RFC-style CSV with quoted commas, escaped quotes and embedded newlines. */
26
+ export function parseTapCsvTable(csv) {
27
+ // Ignore an optional UTF-8 BOM at the beginning of the CSV.
28
+ if (csv.startsWith('\uFEFF')) {
29
+ csv = csv.slice(1);
30
+ }
31
+ const rows = [];
32
+ let row = [], field = "", quoted = false, afterQuote = false;
33
+ for (let i = 0; i < csv.length; i++) {
34
+ const c = csv[i];
35
+ if (quoted) {
36
+ if (c === '"' && csv[i + 1] === '"') {
37
+ field += '"';
38
+ i++;
39
+ }
40
+ else if (c === '"') {
41
+ quoted = false;
42
+ afterQuote = true;
43
+ }
44
+ else
45
+ field += c;
46
+ }
47
+ else if (afterQuote) {
48
+ if (c === ",") {
49
+ row.push(field);
50
+ field = "";
51
+ afterQuote = false;
52
+ }
53
+ else if (c === "\n" || c === "\r") {
54
+ row.push(field);
55
+ rows.push(row);
56
+ row = [];
57
+ field = "";
58
+ afterQuote = false;
59
+ if (c === "\r" && csv[i + 1] === "\n")
60
+ i++;
61
+ }
62
+ else
63
+ throw new ServiceError("INVALID_RESPONSE", "Malformed TAP CSV quoting");
64
+ }
65
+ else if (c === '"') {
66
+ if (field)
67
+ throw new ServiceError("INVALID_RESPONSE", "Malformed TAP CSV quoting");
68
+ quoted = true;
69
+ }
70
+ else if (c === ",") {
71
+ row.push(field);
72
+ field = "";
73
+ }
74
+ else if (c === "\n" || c === "\r") {
75
+ row.push(field);
76
+ rows.push(row);
77
+ row = [];
78
+ field = "";
79
+ if (c === "\r" && csv[i + 1] === "\n")
80
+ i++;
81
+ }
82
+ else
83
+ field += c;
84
+ }
85
+ if (quoted)
86
+ throw new ServiceError("INVALID_RESPONSE", "Unterminated TAP CSV field");
87
+ if (afterQuote || field || row.length) {
88
+ row.push(field);
89
+ rows.push(row);
90
+ }
91
+ if (!rows.length || !rows[0].length || rows[0].some((h) => !h)) {
92
+ throw new ServiceError("INVALID_RESPONSE", "Missing TAP CSV header");
93
+ }
94
+ const [headers, ...data] = rows;
95
+ const columns = headers.map((h) => h.replace(/^\uFEFF/, ""));
96
+ return {
97
+ columns,
98
+ rows: data.map((values) => {
99
+ if (values.length !== headers.length) {
100
+ throw new ServiceError("INVALID_RESPONSE", "TAP CSV column count mismatch");
101
+ }
102
+ return Object.fromEntries(columns.map((name, i) => [name, values[i]]));
103
+ }),
104
+ };
105
+ }
106
+ /**
107
+ * Parse TAP CSV and return data rows.
108
+ * Preserves the existing public API.
109
+ */
110
+ export function parseTapCsv(csv) {
111
+ return parseTapCsvTable(csv).rows;
112
+ }
113
+ function sqlLiteral(value) {
114
+ if (!value.trim())
115
+ throw new TypeError("TAP metadata filter must not be blank");
116
+ return "'" + value.replace(/'/g, "''") + "'";
117
+ }
118
+ /** TAP 1.x synchronous ADQL client. No rendering or backend dependency. */
119
+ export class TapService {
120
+ baseUrl;
121
+ url;
122
+ transport;
123
+ timeoutMs;
124
+ constructor(options) {
125
+ this.baseUrl = tapBaseUrl(options.endpoint);
126
+ this.url = this.baseUrl + "/sync";
127
+ this.transport = options.transport ?? new FetchHttpTransport();
128
+ this.timeoutMs = options.timeoutMs ?? 30000;
129
+ if (!Number.isFinite(this.timeoutMs) || this.timeoutMs <= 0) {
130
+ throw new RangeError("timeoutMs must be a positive finite number");
131
+ }
132
+ }
133
+ /** Raw VOSI capabilities XML; parsing is intentionally left to the caller. */
134
+ async getCapabilities(options = {}) {
135
+ return this.transport.getText(this.baseUrl + "/capabilities", {
136
+ signal: options.signal,
137
+ timeoutMs: this.timeoutMs,
138
+ });
139
+ }
140
+ async metadataRows(adql, signal) {
141
+ const response = await this.querySync({ adql, format: "csv", signal });
142
+ if (response.contentType &&
143
+ !/\b(csv|text\/plain)\b/i.test(response.contentType)) {
144
+ throw new ServiceError("INVALID_RESPONSE", "TAP metadata query did not return CSV");
145
+ }
146
+ return parseTapCsv(response.body);
147
+ }
148
+ async listSchemas(options = {}) {
149
+ return this.metadataRows("SELECT schema_name, description, utype FROM TAP_SCHEMA.schemas", options.signal);
150
+ }
151
+ async listTables(options = {}) {
152
+ const where = options.schemaName === undefined
153
+ ? ""
154
+ : " WHERE schema_name = " + sqlLiteral(options.schemaName);
155
+ return this.metadataRows("SELECT schema_name, table_name, table_type, description, utype FROM TAP_SCHEMA.tables" +
156
+ where, options.signal);
157
+ }
158
+ async listColumns(tableName, options = {}) {
159
+ return this.metadataRows("SELECT table_name, column_name, description, unit, ucd, utype, datatype, arraysize, xtype, principal, indexed, std FROM TAP_SCHEMA.columns WHERE table_name = " +
160
+ sqlLiteral(tableName), options.signal);
161
+ }
162
+ async querySync(query) {
163
+ if (typeof query.adql !== "string" || !query.adql.trim()) {
164
+ throw new TypeError("ADQL query must not be blank");
165
+ }
166
+ if (query.maxrec !== undefined &&
167
+ (!Number.isSafeInteger(query.maxrec) || query.maxrec < 0)) {
168
+ throw new RangeError("maxrec must be a non-negative safe integer");
169
+ }
170
+ const form = new URLSearchParams({
171
+ REQUEST: "doQuery",
172
+ LANG: "ADQL",
173
+ QUERY: query.adql,
174
+ FORMAT: query.format ?? "votable",
175
+ });
176
+ if (query.maxrec !== undefined)
177
+ form.set("MAXREC", String(query.maxrec));
178
+ if (!this.transport.request) {
179
+ throw new ServiceError("INVALID_RESPONSE", "TAP requires an HTTP transport supporting POST requests");
180
+ }
181
+ const response = await this.transport.request(this.url, {
182
+ method: "POST",
183
+ headers: {
184
+ "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
185
+ Accept: "*/*",
186
+ },
187
+ body: form.toString(),
188
+ signal: query.signal,
189
+ timeoutMs: this.timeoutMs,
190
+ });
191
+ return {
192
+ body: response.body,
193
+ status: response.status,
194
+ headers: response.headers,
195
+ contentType: response.headers.get("content-type"),
196
+ };
197
+ }
198
+ }
@@ -0,0 +1,13 @@
1
+ import type { MetadataColumn } from "astrospatial-core/metadata";
2
+ import type { TapColumn } from "./TapService.js";
3
+ export interface VOTableResult {
4
+ columns: MetadataColumn[];
5
+ rows: string[][];
6
+ }
7
+ export interface VOTableOptions {
8
+ /** Output column name -> original TAP_SCHEMA column name. */
9
+ aliases?: Readonly<Record<string, string>>;
10
+ sourceColumns?: readonly TapColumn[];
11
+ }
12
+ /** Parse one XML/TABLEDATA VOTable table; no BINARY, BINARY2 or FITS support yet. */
13
+ export declare function parseVOTable(xml: string, options?: VOTableOptions): VOTableResult;
@@ -0,0 +1,85 @@
1
+ import { DOMParser } from "@xmldom/xmldom";
2
+ import { ServiceError } from "../../core/ServiceError.js";
3
+ import { tapColumnsToMetadata } from "./TapMetadataAdapter.js";
4
+ function children(element, name) {
5
+ return Array.from(element.childNodes)
6
+ .filter((node) => node.nodeType === 1)
7
+ .filter((node) => node.localName === name || node.nodeName === name);
8
+ }
9
+ function descendants(element, name) {
10
+ const found = [];
11
+ for (const child of Array.from(element.childNodes)) {
12
+ if (child.nodeType !== 1)
13
+ continue;
14
+ const el = child;
15
+ if (el.localName === name || el.nodeName === name)
16
+ found.push(el);
17
+ found.push(...descendants(el, name));
18
+ }
19
+ return found;
20
+ }
21
+ /** Parse one XML/TABLEDATA VOTable table; no BINARY, BINARY2 or FITS support yet. */
22
+ export function parseVOTable(xml, options = {}) {
23
+ const errors = [];
24
+ let document;
25
+ try {
26
+ document = new DOMParser({
27
+ errorHandler: (level, message) => {
28
+ if (level !== "warning")
29
+ errors.push(message);
30
+ },
31
+ }).parseFromString(xml, "application/xml");
32
+ }
33
+ catch (cause) {
34
+ throw new ServiceError("INVALID_RESPONSE", "Malformed VOTable XML", undefined, { cause });
35
+ }
36
+ if (errors.length || !document?.documentElement || document.documentElement.localName !== "VOTABLE") {
37
+ throw new ServiceError("INVALID_RESPONSE", "Malformed VOTable XML");
38
+ }
39
+ const tables = descendants(document.documentElement, "TABLE");
40
+ if (tables.length !== 1) {
41
+ throw new ServiceError("INVALID_RESPONSE", "Expected exactly one VOTable TABLE");
42
+ }
43
+ const table = tables[0];
44
+ const data = children(table, "DATA");
45
+ if (data.length !== 1)
46
+ throw new ServiceError("INVALID_RESPONSE", "Missing VOTable DATA");
47
+ const unsupported = ["BINARY", "BINARY2", "FITS"].find((name) => children(data[0], name).length > 0);
48
+ if (unsupported)
49
+ throw new ServiceError("NOT_IMPLEMENTED", `VOTable ${unsupported} serialization is not implemented yet`);
50
+ const tableData = children(data[0], "TABLEDATA");
51
+ if (tableData.length !== 1)
52
+ throw new ServiceError("INVALID_RESPONSE", "Missing VOTable TABLEDATA");
53
+ const fields = children(table, "FIELD");
54
+ if (!fields.length)
55
+ throw new ServiceError("INVALID_RESPONSE", "Missing VOTable FIELD");
56
+ const names = fields.map((field, i) => field.getAttribute("name") || field.getAttribute("ID") || `column_${i}`);
57
+ const source = new Map((options.sourceColumns ?? []).map((col) => [col.column_name, col]));
58
+ const columns = fields.map((field, i) => {
59
+ const name = names[i];
60
+ const original = source.get(options.aliases?.[name] ?? name);
61
+ const description = children(field, "DESCRIPTION")[0]?.textContent?.trim() || "";
62
+ const attr = (key) => field.getAttribute(key) || "";
63
+ return {
64
+ table_name: original?.table_name ?? "",
65
+ column_name: name,
66
+ description: description || original?.description || "",
67
+ unit: attr("unit") || original?.unit || "",
68
+ ucd: attr("ucd") || original?.ucd || "",
69
+ utype: attr("utype") || original?.utype || "",
70
+ datatype: attr("datatype") || original?.datatype || "",
71
+ arraysize: attr("arraysize") || original?.arraysize || "",
72
+ xtype: attr("xtype") || original?.xtype || "",
73
+ principal: original?.principal ?? "",
74
+ indexed: original?.indexed ?? "",
75
+ std: original?.std ?? "",
76
+ };
77
+ });
78
+ const rows = children(tableData[0], "TR").map((tr) => {
79
+ const values = children(tr, "TD").map((td) => td.textContent ?? "");
80
+ if (values.length !== fields.length)
81
+ throw new ServiceError("INVALID_RESPONSE", "VOTable row column count mismatch");
82
+ return values;
83
+ });
84
+ return { columns: tapColumnsToMetadata(columns), rows };
85
+ }
@@ -0,0 +1,9 @@
1
+ export { TapService } from './TapService.js';
2
+ export type { TapServiceOptions, TapSyncQuery, TapQueryResponse, TapOutputFormat } from './TapService.js';
3
+ export { parseTapCsv } from './TapService.js';
4
+ export type { TapSchema, TapTable, TapColumn } from './TapService.js';
5
+ export { tapColumnsToMetadata } from './TapMetadataAdapter.js';
6
+ export { parseTapCsvTable } from './TapService.js';
7
+ export type { TapCsvTable } from './TapService.js';
8
+ export { parseVOTable } from './VOTableParser.js';
9
+ export type { VOTableResult, VOTableOptions } from './VOTableParser.js';
@@ -0,0 +1,5 @@
1
+ export { TapService } from './TapService.js';
2
+ export { parseTapCsv } from './TapService.js';
3
+ export { tapColumnsToMetadata } from './TapMetadataAdapter.js';
4
+ export { parseTapCsvTable } from './TapService.js';
5
+ export { parseVOTable } from './VOTableParser.js';
@@ -0,0 +1,28 @@
1
+ export interface HttpRequestOptions {
2
+ method?: "GET" | "POST";
3
+ headers?: Record<string, string>;
4
+ body?: string;
5
+ signal?: AbortSignal;
6
+ timeoutMs?: number;
7
+ }
8
+ export interface HttpResponse {
9
+ status: number;
10
+ headers: Headers;
11
+ body: string;
12
+ }
13
+ export interface HttpTransport {
14
+ getText(url: string, options?: {
15
+ signal?: AbortSignal;
16
+ timeoutMs?: number;
17
+ }): Promise<string>;
18
+ request?(url: string, options?: HttpRequestOptions): Promise<HttpResponse>;
19
+ }
20
+ export declare class FetchHttpTransport implements HttpTransport {
21
+ private readonly fetchImpl;
22
+ constructor(fetchImpl?: typeof fetch);
23
+ getText(url: string, options?: {
24
+ signal?: AbortSignal;
25
+ timeoutMs?: number;
26
+ }): Promise<string>;
27
+ request(url: string, options?: HttpRequestOptions): Promise<HttpResponse>;
28
+ }
@@ -0,0 +1,57 @@
1
+ import { ServiceError } from "./ServiceError.js";
2
+ export class FetchHttpTransport {
3
+ fetchImpl;
4
+ constructor(fetchImpl = fetch) {
5
+ this.fetchImpl = fetchImpl;
6
+ }
7
+ async getText(url, options = {}) {
8
+ const response = await this.request(url, { ...options, method: "GET" });
9
+ return response.body;
10
+ }
11
+ async request(url, options = {}) {
12
+ const timeoutMs = options.timeoutMs ?? 10000;
13
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
14
+ throw new RangeError("timeoutMs must be a positive finite number");
15
+ }
16
+ const method = options.method ?? "GET";
17
+ if (method === "GET" && options.body !== undefined) {
18
+ throw new TypeError("GET requests cannot have a body");
19
+ }
20
+ const controller = new AbortController();
21
+ const timeout = setTimeout(() => controller.abort("timeout"), timeoutMs);
22
+ const onAbort = () => controller.abort("caller");
23
+ options.signal?.addEventListener("abort", onAbort, { once: true });
24
+ if (options.signal?.aborted)
25
+ controller.abort("caller");
26
+ try {
27
+ const response = await this.fetchImpl(url, {
28
+ method,
29
+ headers: options.headers,
30
+ body: options.body,
31
+ signal: controller.signal,
32
+ });
33
+ if (!response.ok)
34
+ throw new ServiceError("HTTP", `HTTP ${response.status} for ${url}`, response.status);
35
+ return {
36
+ status: response.status,
37
+ headers: response.headers,
38
+ body: await response.text(),
39
+ };
40
+ }
41
+ catch (error) {
42
+ if (error instanceof ServiceError)
43
+ throw error;
44
+ if (controller.signal.aborted) {
45
+ const callerAborted = options.signal?.aborted || controller.signal.reason === "caller";
46
+ throw new ServiceError(callerAborted ? "ABORTED" : "TIMEOUT", callerAborted
47
+ ? "Request aborted"
48
+ : `Request timed out after ${timeoutMs}ms`, undefined, { cause: error });
49
+ }
50
+ throw new ServiceError("NETWORK", `Network error while requesting ${url}`, undefined, { cause: error });
51
+ }
52
+ finally {
53
+ clearTimeout(timeout);
54
+ options.signal?.removeEventListener("abort", onAbort);
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,6 @@
1
+ export type ServiceErrorCode = 'NETWORK' | 'HTTP' | 'TIMEOUT' | 'ABORTED' | 'INVALID_RESPONSE' | 'NOT_FOUND' | 'NOT_IMPLEMENTED';
2
+ export declare class ServiceError extends Error {
3
+ readonly code: ServiceErrorCode;
4
+ readonly status?: number | undefined;
5
+ constructor(code: ServiceErrorCode, message: string, status?: number | undefined, options?: ErrorOptions);
6
+ }
@@ -0,0 +1,10 @@
1
+ export class ServiceError extends Error {
2
+ code;
3
+ status;
4
+ constructor(code, message, status, options) {
5
+ super(message, options);
6
+ this.code = code;
7
+ this.status = status;
8
+ this.name = 'ServiceError';
9
+ }
10
+ }
@@ -0,0 +1,7 @@
1
+ export { FetchHttpTransport } from './core/HttpTransport.js';
2
+ export type { HttpTransport, HttpRequestOptions, HttpResponse } from './core/HttpTransport.js';
3
+ export { ServiceError } from './core/ServiceError.js';
4
+ export type { ServiceErrorCode } from './core/ServiceError.js';
5
+ export * from './astronomy/name-resolver/index.js';
6
+ export * from './astronomy/hips/index.js';
7
+ export * from './astronomy/tap/index.js';
@@ -0,0 +1,5 @@
1
+ export { FetchHttpTransport } from './core/HttpTransport.js';
2
+ export { ServiceError } from './core/ServiceError.js';
3
+ export * from './astronomy/name-resolver/index.js';
4
+ export * from './astronomy/hips/index.js';
5
+ export * from './astronomy/tap/index.js';
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "astrospatial-services",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Render-independent scientific service clients for astronomy, Earth observation and planetary data.",
5
+ "type": "module",
6
+ "main": "./lib-esm/index.js",
7
+ "types": "./lib-esm/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib-esm/index.d.ts",
11
+ "import": "./lib-esm/index.js"
12
+ },
13
+ "./astronomy/name-resolver": {
14
+ "types": "./lib-esm/astronomy/name-resolver/index.d.ts",
15
+ "import": "./lib-esm/astronomy/name-resolver/index.js"
16
+ },
17
+ "./package.json": "./package.json",
18
+ "./astronomy/hips": {
19
+ "types": "./lib-esm/astronomy/hips/index.d.ts",
20
+ "import": "./lib-esm/astronomy/hips/index.js"
21
+ },
22
+ "./astronomy/tap": {
23
+ "types": "./lib-esm/astronomy/tap/index.d.ts",
24
+ "import": "./lib-esm/astronomy/tap/index.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "lib-esm/",
29
+ "README.md",
30
+ "LICENSE.md",
31
+ "LICENSE-COMMERCIAL.md",
32
+ "LICENSE-AGPL.md",
33
+ "DEPENDENCY-LICENSING.md",
34
+ "package.json"
35
+ ],
36
+ "sideEffects": false,
37
+ "engines": {
38
+ "node": ">=22.0.0"
39
+ },
40
+ "scripts": {
41
+ "clean": "rm -rf lib-esm",
42
+ "build": "tsc -p tsconfig.build.json",
43
+ "test": "npm run build && node --test test/*.test.mjs",
44
+ "test:live": "npm run build && node --test test/*.live.test.mjs"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public",
48
+ "registry": "https://registry.npmjs.org/"
49
+ },
50
+ "author": "Fabrizio Giordano (fab77)",
51
+ "license": "SEE LICENSE IN LICENSE.md",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/fab77/astrospatial-services.git"
55
+ },
56
+ "devDependencies": {
57
+ "typescript": "^5.0.0"
58
+ },
59
+ "dependencies": {
60
+ "astrospatial-core": "0.4.2",
61
+ "@xmldom/xmldom": "^0.9.8"
62
+ }
63
+ }