@lde/distribution-probe 0.1.3 → 0.1.5

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.
package/dist/probe.d.ts CHANGED
@@ -46,8 +46,14 @@ declare abstract class ProbeResult {
46
46
  * Result of probing a SPARQL endpoint.
47
47
  */
48
48
  export declare class SparqlProbeResult extends ProbeResult {
49
- readonly acceptedContentType: string;
50
- constructor(url: string, response: Response, responseTimeMs: number, acceptedContentType: string, failureReason?: string | null);
49
+ /**
50
+ * Content types the probe was prepared to accept as a valid answer. A SELECT or
51
+ * ASK query may be answered with SPARQL results in JSON or XML; the endpoint
52
+ * chooses, so success is not tied to a single serialization. A single string is
53
+ * accepted and normalized to a one-element list for backwards compatibility.
54
+ */
55
+ readonly acceptedContentTypes: readonly string[];
56
+ constructor(url: string, response: Response, responseTimeMs: number, acceptedContentTypes: string | readonly string[], failureReason?: string | null);
51
57
  isSuccess(): boolean;
52
58
  }
53
59
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"probe.d.ts","sourceRoot":"","sources":["../src/probe.ts"],"names":[],"mappings":"AAAA,OAAO,EAAyB,YAAY,EAAE,MAAM,cAAc,CAAC;AAGnE;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,0DAA0D;IAC1D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAKD;;GAEG;AACH,qBAAa,YAAY;aAEL,GAAG,EAAE,MAAM;aACX,OAAO,EAAE,MAAM;aACf,cAAc,EAAE,MAAM;gBAFtB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,EACf,cAAc,EAAE,MAAM;CAEzC;AAED;;GAEG;AACH,uBAAe,WAAW;aAUN,GAAG,EAAE,MAAM;IAT7B,SAAgB,UAAU,EAAE,MAAM,CAAC;IACnC,SAAgB,UAAU,EAAE,MAAM,CAAC;IACnC,SAAgB,YAAY,EAAE,IAAI,GAAG,IAAI,CAAQ;IACjD,SAAgB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,SAAgB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7C,SAAgB,QAAQ,EAAE,MAAM,EAAE,CAAM;IACxC,SAAgB,cAAc,EAAE,MAAM,CAAC;gBAGrB,GAAG,EAAE,MAAM,EAC3B,QAAQ,EAAE,QAAQ,EAClB,cAAc,EAAE,MAAM,EACtB,aAAa,GAAE,MAAM,GAAG,IAAW;IAa9B,SAAS,IAAI,OAAO;CAO5B;AAKD;;GAEG;AACH,qBAAa,iBAAkB,SAAQ,WAAW;IAChD,SAAgB,mBAAmB,EAAE,MAAM,CAAC;gBAG1C,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,QAAQ,EAClB,cAAc,EAAE,MAAM,EACtB,mBAAmB,EAAE,MAAM,EAC3B,aAAa,GAAE,MAAM,GAAG,IAAW;IAM5B,SAAS,IAAI,OAAO;CAM9B;AAED;;GAEG;AACH,qBAAa,mBAAoB,SAAQ,WAAW;IAClD,SAAgB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAQ;gBAGhD,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,QAAQ,EAClB,cAAc,EAAE,MAAM,EACtB,aAAa,GAAE,MAAM,GAAG,IAAW;CAQtC;AAED,MAAM,MAAM,eAAe,GACvB,iBAAiB,GACjB,mBAAmB,GACnB,YAAY,CAAC;AAIjB;;;;;;;;GAQG;AACH,wBAAsB,KAAK,CACzB,YAAY,EAAE,YAAY,EAC1B,OAAO,CAAC,EAAE,YAAY,GACrB,OAAO,CAAC,eAAe,CAAC,CAkC1B"}
1
+ {"version":3,"file":"probe.d.ts","sourceRoot":"","sources":["../src/probe.ts"],"names":[],"mappings":"AAAA,OAAO,EAAyB,YAAY,EAAE,MAAM,cAAc,CAAC;AAGnE;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,0DAA0D;IAC1D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAKD;;GAEG;AACH,qBAAa,YAAY;aAEL,GAAG,EAAE,MAAM;aACX,OAAO,EAAE,MAAM;aACf,cAAc,EAAE,MAAM;gBAFtB,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,MAAM,EACf,cAAc,EAAE,MAAM;CAEzC;AAED;;GAEG;AACH,uBAAe,WAAW;aAUN,GAAG,EAAE,MAAM;IAT7B,SAAgB,UAAU,EAAE,MAAM,CAAC;IACnC,SAAgB,UAAU,EAAE,MAAM,CAAC;IACnC,SAAgB,YAAY,EAAE,IAAI,GAAG,IAAI,CAAQ;IACjD,SAAgB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,SAAgB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7C,SAAgB,QAAQ,EAAE,MAAM,EAAE,CAAM;IACxC,SAAgB,cAAc,EAAE,MAAM,CAAC;gBAGrB,GAAG,EAAE,MAAM,EAC3B,QAAQ,EAAE,QAAQ,EAClB,cAAc,EAAE,MAAM,EACtB,aAAa,GAAE,MAAM,GAAG,IAAW;IAa9B,SAAS,IAAI,OAAO;CAO5B;AAMD;;GAEG;AACH,qBAAa,iBAAkB,SAAQ,WAAW;IAChD;;;;;OAKG;IACH,SAAgB,oBAAoB,EAAE,SAAS,MAAM,EAAE,CAAC;gBAGtD,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,QAAQ,EAClB,cAAc,EAAE,MAAM,EACtB,oBAAoB,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,EAChD,aAAa,GAAE,MAAM,GAAG,IAAW;IAS5B,SAAS,IAAI,OAAO;CAQ9B;AAED;;GAEG;AACH,qBAAa,mBAAoB,SAAQ,WAAW;IAClD,SAAgB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAQ;gBAGhD,GAAG,EAAE,MAAM,EACX,QAAQ,EAAE,QAAQ,EAClB,cAAc,EAAE,MAAM,EACtB,aAAa,GAAE,MAAM,GAAG,IAAW;CAQtC;AAED,MAAM,MAAM,eAAe,GACvB,iBAAiB,GACjB,mBAAmB,GACnB,YAAY,CAAC;AAIjB;;;;;;;;GAQG;AACH,wBAAsB,KAAK,CACzB,YAAY,EAAE,YAAY,EAC1B,OAAO,CAAC,EAAE,YAAY,GACrB,OAAO,CAAC,eAAe,CAAC,CAkC1B"}
package/dist/probe.js CHANGED
@@ -46,19 +46,29 @@ class ProbeResult {
46
46
  }
47
47
  }
48
48
  const SPARQL_RESULTS_JSON = 'application/sparql-results+json';
49
+ const SPARQL_RESULTS_XML = 'application/sparql-results+xml';
49
50
  const SPARQL_RDF_RESULTS = 'application/n-triples';
50
51
  /**
51
52
  * Result of probing a SPARQL endpoint.
52
53
  */
53
54
  export class SparqlProbeResult extends ProbeResult {
54
- acceptedContentType;
55
- constructor(url, response, responseTimeMs, acceptedContentType, failureReason = null) {
55
+ /**
56
+ * Content types the probe was prepared to accept as a valid answer. A SELECT or
57
+ * ASK query may be answered with SPARQL results in JSON or XML; the endpoint
58
+ * chooses, so success is not tied to a single serialization. A single string is
59
+ * accepted and normalized to a one-element list for backwards compatibility.
60
+ */
61
+ acceptedContentTypes;
62
+ constructor(url, response, responseTimeMs, acceptedContentTypes, failureReason = null) {
56
63
  super(url, response, responseTimeMs, failureReason);
57
- this.acceptedContentType = acceptedContentType;
64
+ this.acceptedContentTypes =
65
+ typeof acceptedContentTypes === 'string'
66
+ ? [acceptedContentTypes]
67
+ : acceptedContentTypes;
58
68
  }
59
69
  isSuccess() {
60
70
  return (super.isSuccess() &&
61
- (this.contentType?.startsWith(this.acceptedContentType) ?? false));
71
+ this.acceptedContentTypes.some((type) => this.contentType?.startsWith(type) ?? false));
62
72
  }
63
73
  }
64
74
  /**
@@ -137,18 +147,33 @@ function detectSparqlQueryType(query) {
137
147
  const match = /\b(ASK|SELECT|CONSTRUCT|DESCRIBE)\b/i.exec(withoutComments);
138
148
  return (match?.[1].toUpperCase() ?? 'SELECT');
139
149
  }
140
- function acceptHeaderForQueryType(queryType) {
150
+ /**
151
+ * Content types a SPARQL endpoint may legitimately answer with, in preference
152
+ * order, for the given query type. SELECT and ASK return a results document
153
+ * (JSON or XML – the endpoint chooses); CONSTRUCT and DESCRIBE return RDF.
154
+ */
155
+ function acceptableContentTypes(queryType) {
141
156
  if (queryType === 'ASK' || queryType === 'SELECT') {
142
- return SPARQL_RESULTS_JSON;
157
+ return [SPARQL_RESULTS_JSON, SPARQL_RESULTS_XML];
143
158
  }
144
- return SPARQL_RDF_RESULTS;
159
+ return [SPARQL_RDF_RESULTS];
160
+ }
161
+ /**
162
+ * Build an `Accept` header that prefers the first content type but still accepts
163
+ * the rest at a lower q-value, so an endpoint that only serves a later type is
164
+ * not rejected with a 406.
165
+ */
166
+ function acceptHeader(contentTypes) {
167
+ return contentTypes
168
+ .map((type, index) => (index === 0 ? type : `${type};q=0.9`))
169
+ .join(', ');
145
170
  }
146
171
  async function probeSparqlEndpoint(url, _distribution, options, authHeaders, start) {
147
172
  const queryType = detectSparqlQueryType(options.sparqlQuery);
148
- const accept = acceptHeaderForQueryType(queryType);
173
+ const acceptedContentTypes = acceptableContentTypes(queryType);
149
174
  const headers = new Headers({
150
175
  'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
151
- Accept: accept,
176
+ Accept: acceptHeader(acceptedContentTypes),
152
177
  });
153
178
  for (const [key, value] of authHeaders) {
154
179
  headers.set(key, value);
@@ -160,19 +185,19 @@ async function probeSparqlEndpoint(url, _distribution, options, authHeaders, sta
160
185
  body: `query=${encodeURIComponent(options.sparqlQuery)}`,
161
186
  });
162
187
  const actualContentType = response.headers.get('Content-Type');
163
- const contentTypeMatches = actualContentType?.startsWith(accept) ?? false;
188
+ const matchedContentType = acceptedContentTypes.find((type) => actualContentType?.startsWith(type) ?? false);
164
189
  let failureReason = null;
165
- if (response.ok && contentTypeMatches) {
166
- failureReason = await validateSparqlResponse(response, queryType);
190
+ if (response.ok && matchedContentType !== undefined) {
191
+ failureReason = await validateSparqlResponse(response, queryType, matchedContentType);
167
192
  }
168
193
  else {
169
194
  // Drain unconsumed body to release the underlying connection.
170
195
  await response.body?.cancel();
171
196
  }
172
197
  const responseTimeMs = Math.round(performance.now() - start);
173
- return new SparqlProbeResult(url, response, responseTimeMs, accept, failureReason);
198
+ return new SparqlProbeResult(url, response, responseTimeMs, acceptedContentTypes, failureReason);
174
199
  }
175
- async function validateSparqlResponse(response, queryType) {
200
+ async function validateSparqlResponse(response, queryType, contentType) {
176
201
  const body = await response.text();
177
202
  if (body.length === 0) {
178
203
  return 'SPARQL endpoint returned an empty response';
@@ -182,6 +207,11 @@ async function validateSparqlResponse(response, queryType) {
182
207
  // endpoint answered. Deep parse validation is the data-dump path’s job.
183
208
  return null;
184
209
  }
210
+ return contentType.startsWith(SPARQL_RESULTS_XML)
211
+ ? validateSparqlXmlResults(body, queryType)
212
+ : validateSparqlJsonResults(body, queryType);
213
+ }
214
+ function validateSparqlJsonResults(body, queryType) {
185
215
  let json;
186
216
  try {
187
217
  json = JSON.parse(body);
@@ -201,9 +231,38 @@ async function validateSparqlResponse(response, queryType) {
201
231
  }
202
232
  return null;
203
233
  }
234
+ /**
235
+ * Lightweight structural check on a SPARQL Query Results XML document. Mirrors
236
+ * the JSON path’s intent – confirm the endpoint answered with the expected shape
237
+ * – without pulling in a full XML parser.
238
+ */
239
+ function validateSparqlXmlResults(body, queryType) {
240
+ if (!/<sparql[\s>]/i.test(body)) {
241
+ return 'SPARQL endpoint returned invalid XML';
242
+ }
243
+ if (queryType === 'ASK') {
244
+ if (!/<boolean>\s*(true|false)\s*<\/boolean>/i.test(body)) {
245
+ return 'SPARQL endpoint did not return a valid ASK result';
246
+ }
247
+ return null;
248
+ }
249
+ // SELECT
250
+ if (!/<results[\s/>]/i.test(body)) {
251
+ return 'SPARQL endpoint did not return a valid results object';
252
+ }
253
+ return null;
254
+ }
204
255
  async function probeDataDump(url, distribution, options, authHeaders, start) {
256
+ // Express a preference for the declared media type, but accept anything as a
257
+ // fallback. Servers that implement RFC 9110 §12.5.1 content negotiation will
258
+ // pick the declared type (preserving our ability to detect real Content-Type
259
+ // mismatches). Servers that reject any non-*/* Accept with 406 — notably
260
+ // Dataverse's /api/access/datafile/ endpoint (IQSS/dataverse#12410) — fall
261
+ // back to */* and return the file unchanged.
205
262
  const headers = new Headers({
206
- Accept: distribution.mimeType ?? '*/*',
263
+ Accept: distribution.mimeType
264
+ ? `${distribution.mimeType}, */*;q=0.5`
265
+ : '*/*',
207
266
  'Accept-Encoding': 'identity',
208
267
  });
209
268
  for (const [key, value] of authHeaders) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lde/distribution-probe",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "repository": {
5
5
  "url": "git+https://github.com/ldelements/lde.git",
6
6
  "directory": "packages/distribution-probe"