• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

knowledgepixels / nanopub-query / 30447244182

29 Jul 2026 11:21AM UTC coverage: 59.763% (-0.02%) from 59.778%
30447244182

push

github

web-flow
Merge pull request #143 from knowledgepixels/feat/internal-federation-url

feat(api): route federated SERVICE traffic in-cluster via NANOPUB_QUERY_INTERNAL_URL

620 of 1168 branches covered (53.08%)

Branch coverage included in aggregate %.

1801 of 2883 relevant lines covered (62.47%)

9.59 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

68.28
src/main/java/com/knowledgepixels/query/GrlcSpec.java
1
package com.knowledgepixels.query;
2

3
import io.vertx.core.MultiMap;
4
import net.trustyuri.TrustyUriUtils;
5
import org.eclipse.rdf4j.model.IRI;
6
import org.eclipse.rdf4j.model.vocabulary.DCTERMS;
7
import org.eclipse.rdf4j.query.QueryLanguage;
8
import org.eclipse.rdf4j.query.TupleQueryResult;
9
import org.eclipse.rdf4j.repository.RepositoryConnection;
10
import org.eclipse.rdf4j.rio.RDFFormat;
11
import org.nanopub.MalformedNanopubException;
12
import org.nanopub.Nanopub;
13
import org.nanopub.NanopubImpl;
14
import org.nanopub.SimpleCreatorPattern;
15
import org.nanopub.extra.server.GetNanopub;
16
import org.nanopub.extra.services.QueryTemplate;
17
import org.nanopub.vocabulary.NPA;
18
import org.nanopub.vocabulary.NPX;
19
import org.slf4j.Logger;
20
import org.slf4j.LoggerFactory;
21

22
import java.io.ByteArrayInputStream;
23
import java.io.IOException;
24
import java.util.ArrayList;
25
import java.util.Base64;
26
import java.util.LinkedHashMap;
27
import java.util.List;
28
import java.util.Map;
29
import java.util.Set;
30
import java.util.concurrent.ConcurrentHashMap;
31

32
/**
33
 * Nanopub Query-specific wrapper around {@link QueryTemplate} that adds:
34
 * <ul>
35
 *   <li>request-URL parsing ({@code /…/RA…/{name}.rq})</li>
36
 *   <li>nanopub fetch cache</li>
37
 *   <li>the {@code _nanopub_trig} inline-nanopub parameter</li>
38
 *   <li>{@code api-version=latest} resolution against the local meta repo</li>
39
 *   <li>rewriting the canonical {@code https://w3id.org/np/l/nanopub-query-1.1/repo/}
40
 *       endpoint prefix to the in-cluster {@code NANOPUB_QUERY_INTERNAL_URL/repo/}, plus
41
 *       validation that the endpoint matches the canonical prefix</li>
42
 *   <li>{@link #getSpec()} YAML rendering for the legacy {@code /grlc-spec/} route</li>
43
 *   <li>{@link #getRepoName()} derived from the rewritten endpoint</li>
44
 * </ul>
45
 * <p>Parsing, placeholder extraction and SPARQL expansion are delegated to
46
 * {@link QueryTemplate}. Static placeholder helpers are forwarded so existing
47
 * callers ({@link OpenApiSpecPage}) keep compiling.
48
 */
49
public class GrlcSpec {
50

51
    private static final Logger logger = LoggerFactory.getLogger(GrlcSpec.class);
9✔
52

53
    private static final ConcurrentHashMap<String, Nanopub> nanopubCache = new ConcurrentHashMap<>();
12✔
54

55
    /**
56
     * Exception for invalid grlc specifications.
57
     */
58
    public static class InvalidGrlcSpecException extends Exception {
59

60
        private InvalidGrlcSpecException(String msg) {
61
            super(msg);
9✔
62
        }
3✔
63

64
        private InvalidGrlcSpecException(String msg, Throwable throwable) {
65
            super(msg, throwable);
12✔
66
        }
3✔
67

68
    }
69

70
    /**
71
     * Public base URL of this Nanopub Query instance, used only in generated
72
     * documentation: the grlc spec's query listing here and the OpenAPI server
73
     * URL in {@link OpenApiSpecPage}. Never used for query execution — that is
74
     * {@link #nanopubQueryInternalUrl}'s job.
75
     */
76
    public static final String nanopubQueryUrl = Utils.getEnvString("NANOPUB_QUERY_URL", "http://query:9393/");
12✔
77

78
    /**
79
     * In-cluster base URL of this Nanopub Query instance, used to rewrite the
80
     * canonical {@code https://w3id.org/np/l/nanopub-query-1.1/repo/} prefix in
81
     * query endpoints and SPARQL {@code SERVICE} clauses. These URLs are
82
     * resolved by the backend RDF4J server when it evaluates the federated
83
     * query, so they must stay inside the cluster: the default resolves to this
84
     * app's docker-compose service address, whose {@code /repo/*} proxy forwards
85
     * to the RDF4J server directly. Routing this traffic through the public
86
     * edge instead (as happened when it shared {@code NANOPUB_QUERY_URL}) makes
87
     * every federated {@code /api} call compete with external clients for
88
     * nginx's per-repo connection limits and adds TLS/proxy overhead on the
89
     * hottest repos (issues #142, incident 2026-07-28). Only set
90
     * {@code NANOPUB_QUERY_INTERNAL_URL} if your deployment reaches the app
91
     * under a different in-cluster address.
92
     */
93
    public static final String nanopubQueryInternalUrl = Utils.getEnvString("NANOPUB_QUERY_INTERNAL_URL", "http://query:9393/");
15✔
94

95
    private static final String NANOPUB_QUERY_REPO_URL = "https://w3id.org/np/l/nanopub-query-1.1/repo/";
96

97
    private final MultiMap parameters;
98
    private final QueryTemplate template;
99
    private final String requestUrlBase;
100
    private final String artifactCode;
101
    private final String queryPart;
102
    private final String queryContent;
103
    private final String endpoint;
104

105
    /**
106
     * Creates a new page instance.
107
     *
108
     * @param requestUrl The request URL
109
     * @param parameters The URL request parameters
110
     */
111
    public GrlcSpec(String requestUrl, MultiMap parameters) throws InvalidGrlcSpecException {
6✔
112
        this.parameters = parameters;
9✔
113
        requestUrl = requestUrl.replaceFirst("\\?.*$", "");
15✔
114
        if (!requestUrl.matches(".*/RA[A-Za-z0-9\\-_]{43}/(.*)?")) {
12✔
115
            throw new InvalidGrlcSpecException("Invalid grlc API request: " + requestUrl);
18✔
116
        }
117
        String parsedArtifactCode = requestUrl.replaceFirst("^(.*/)(RA[A-Za-z0-9\\-_]{43})/(.*)?$", "$2");
15✔
118
        requestUrlBase = requestUrl.replaceFirst("^/(.*/)(RA[A-Za-z0-9\\-_]{43})/(.*)?$", "$1");
18✔
119

120
        String parsedQueryPart = requestUrl.replaceFirst("^(.*/)(RA[A-Za-z0-9\\-_]{43}/)(.*)?$", "$3");
15✔
121
        parsedQueryPart = parsedQueryPart.replaceFirst(".rq$", "");
15✔
122
        queryPart = parsedQueryPart;
9✔
123

124
        Nanopub np;
125
        String nanopubParam = parameters.get("_nanopub_trig");
12✔
126
        if (nanopubParam != null && !nanopubParam.isEmpty()) {
6!
127
            try {
128
                byte[] trig = Base64.getUrlDecoder().decode(nanopubParam);
×
129
                np = new NanopubImpl(new ByteArrayInputStream(trig), RDFFormat.TRIG);
×
130
            } catch (MalformedNanopubException | IOException | IllegalArgumentException ex) {
×
131
                throw new InvalidGrlcSpecException("Failed to parse nanopub from 'nanopub' parameter", ex);
×
132
            }
×
133
        } else {
134
            np = nanopubCache.computeIfAbsent(parsedArtifactCode, GetNanopub::get);
18✔
135
        }
136
        // TODO rename "api-version" to "_api_version" for consistency
137
        if (parameters.get("api-version") != null && parameters.get("api-version").equals("latest")) {
30✔
138
            String latestUri = getLatestVersionIdLocally(np.getUri().stringValue());
15✔
139
            if (!latestUri.equals(np.getUri().stringValue())) {
18!
140
                np = nanopubCache.computeIfAbsent(TrustyUriUtils.getArtifactCode(latestUri), GetNanopub::get);
×
141
            }
142
            parsedArtifactCode = TrustyUriUtils.getArtifactCode(np.getUri().stringValue());
15✔
143
        }
144
        artifactCode = parsedArtifactCode;
9✔
145

146
        try {
147
            if (queryPart.isEmpty()) {
12✔
148
                template = new QueryTemplate(np);
21✔
149
            } else {
150
                template = new QueryTemplate(np, artifactCode + "/" + queryPart);
33✔
151
            }
152
        } catch (IllegalArgumentException ex) {
3✔
153
            throw new InvalidGrlcSpecException(ex.getMessage(), ex);
21✔
154
        }
3✔
155

156
        if (!queryPart.isEmpty() && !queryPart.equals(template.getQuerySuffix())) {
33!
157
            throw new InvalidGrlcSpecException(
×
158
                    "Query part doesn't match query name: " + queryPart + " / " + template.getQuerySuffix());
×
159
        }
160

161
        queryContent = template.getSparql().replace(NANOPUB_QUERY_REPO_URL, nanopubQueryInternalUrl + "repo/");
27✔
162

163
        IRI rawEndpoint = template.getEndpoint();
12✔
164
        if (rawEndpoint != null) {
6!
165
            String ep = rawEndpoint.stringValue();
9✔
166
            if (!ep.startsWith(NANOPUB_QUERY_REPO_URL)) {
12!
167
                throw new InvalidGrlcSpecException("Invalid/non-recognized endpoint: " + ep);
×
168
            }
169
            endpoint = ep.replace(NANOPUB_QUERY_REPO_URL, nanopubQueryInternalUrl + "repo/");
21✔
170
        } else {
3✔
171
            endpoint = null;
×
172
        }
173
    }
3✔
174

175
    /**
176
     * Returns the grlc spec as a string.
177
     *
178
     * @return grlc specification string
179
     */
180
    public String getSpec() {
181
        String s = "";
6✔
182
        String label = template.getLabel();
12✔
183
        String desc = template.getDescription();
12✔
184
        IRI license = template.getLicense();
12✔
185
        String queryName = template.getQuerySuffix();
12✔
186
        if (queryPart.isEmpty()) {
12✔
187
            if (label == null) {
6!
188
                s += "title: \"untitled query\"\n";
×
189
            } else {
190
                s += "title: \"" + QueryTemplate.escapeLiteral(label) + "\"\n";
15✔
191
            }
192
            s += "description: \"" + QueryTemplate.escapeLiteral(desc) + "\"\n";
15✔
193
            StringBuilder userName = new StringBuilder();
12✔
194
            Set<IRI> creators = SimpleCreatorPattern.getCreators(template.getNanopub());
15✔
195
            for (IRI userIri : creators) {
30✔
196
                userName.append(", ").append(userIri);
18✔
197
            }
3✔
198
            if (!userName.isEmpty()) {
9!
199
                userName = new StringBuilder(userName.substring(2));
21✔
200
            }
201
            String url = "";
6✔
202
            if (!creators.isEmpty()) {
9!
203
                url = creators.iterator().next().stringValue();
18✔
204
            }
205
            s += "contact:\n";
9✔
206
            s += "  name: \"" + QueryTemplate.escapeLiteral(userName.toString()) + "\"\n";
18✔
207
            s += "  url: " + url + "\n";
12✔
208
            if (license != null) {
6!
209
                s += "licence: " + license.stringValue() + "\n";
15✔
210
            }
211
            s += "queries:\n";
9✔
212
            s += "  - " + nanopubQueryUrl + requestUrlBase + artifactCode + "/" + queryName + ".rq";
27✔
213
        } else if (queryPart.equals(queryName)) {
18!
214
            if (label != null) {
6!
215
                s += "#+ summary: \"" + QueryTemplate.escapeLiteral(label) + "\"\n";
15✔
216
            }
217
            if (desc != null) {
6!
218
                s += "#+ description: \"" + QueryTemplate.escapeLiteral(desc) + "\"\n";
15✔
219
            }
220
            if (license != null) {
6!
221
                s += "#+ licence: " + license.stringValue() + "\n";
15✔
222
            }
223
            if (endpoint != null) {
9!
224
                s += "#+ endpoint: " + endpoint + "\n";
15✔
225
            }
226
            s += "\n";
9✔
227
            s += queryContent;
18✔
228
        } else {
229
            throw new RuntimeException("Unexpected queryPart: " + queryPart);
×
230
        }
231
        return s;
6✔
232
    }
233

234
    /**
235
     * Returns the request parameters.
236
     *
237
     * @return the request parameters
238
     */
239
    public MultiMap getParameters() {
240
        return parameters;
9✔
241
    }
242

243
    /**
244
     * Returns the nanopub.
245
     *
246
     * @return the nanopub
247
     */
248
    public Nanopub getNanopub() {
249
        return template.getNanopub();
12✔
250
    }
251

252
    /**
253
     * Returns the artifact code.
254
     *
255
     * @return the artifact code
256
     */
257
    public String getArtifactCode() {
258
        return artifactCode;
9✔
259
    }
260

261
    /**
262
     * Returns the label.
263
     *
264
     * @return the label
265
     */
266
    public String getLabel() {
267
        return template.getLabel();
12✔
268
    }
269

270
    /**
271
     * Returns the description.
272
     *
273
     * @return the description
274
     */
275
    public String getDescription() {
276
        return template.getDescription();
12✔
277
    }
278

279
    /**
280
     * Returns the query name.
281
     *
282
     * @return the query name
283
     */
284
    public String getQueryName() {
285
        return template.getQuerySuffix();
12✔
286
    }
287

288
    /**
289
     * Returns the list of placeholders.
290
     *
291
     * @return the list of placeholders
292
     */
293
    public List<String> getPlaceholdersList() {
294
        return template.getPlaceholdersList();
12✔
295
    }
296

297
    /**
298
     * Returns the repository name derived from the endpoint URL.
299
     *
300
     * @return the repository name
301
     */
302
    public String getRepoName() {
303
        return endpoint.replaceAll("/", "_").replaceFirst("^.*_repo_", "");
27✔
304
    }
305

306
    /**
307
     * Returns the query content (with the canonical repo URL rewritten to the
308
     * in-cluster {@link #nanopubQueryInternalUrl}{@code /repo/}).
309
     *
310
     * @return the query content
311
     */
312
    public String getQueryContent() {
313
        return queryContent;
9✔
314
    }
315

316
    public boolean isConstructQuery() {
317
        return template.isConstructQuery();
12✔
318
    }
319

320
    /**
321
     * Expands the query by replacing the placeholders with the provided parameter
322
     * values, and rewrites the canonical repo URL to the in-cluster one.
323
     *
324
     * @return the expanded query
325
     * @throws InvalidGrlcSpecException if a non-optional placeholder is missing a value
326
     */
327
    public String expandQuery() throws InvalidGrlcSpecException {
328
        Map<String, List<String>> params = new LinkedHashMap<>();
×
329
        for (String name : parameters.names()) {
×
330
            params.put(name, new ArrayList<>(parameters.getAll(name)));
×
331
        }
×
332
        logger.info("Expanding grlc query with parameters: {}", parameters);
×
333
        try {
334
            String expanded = template.expandQuery(params);
×
335
            return expanded.replace(NANOPUB_QUERY_REPO_URL, nanopubQueryInternalUrl + "repo/");
×
336
        } catch (IllegalArgumentException ex) {
×
337
            throw new InvalidGrlcSpecException(ex.getMessage(), ex);
×
338
        }
339
    }
340

341
    /**
342
     * Escapes a literal string for SPARQL.
343
     *
344
     * @param s The string
345
     * @return The escaped string
346
     */
347
    public static String escapeLiteral(String s) {
348
        return QueryTemplate.escapeLiteral(s);
×
349
    }
350

351
    /**
352
     * Checks whether the given placeholder is an optional placeholder.
353
     *
354
     * @param placeholder The placeholder name
355
     * @return true if it is an optional placeholder, false otherwise
356
     */
357
    public static boolean isOptionalPlaceholder(String placeholder) {
358
        return QueryTemplate.isOptionalPlaceholder(placeholder);
9✔
359
    }
360

361
    /**
362
     * Checks whether the given placeholder is a multi-value placeholder.
363
     *
364
     * @param placeholder The placeholder name
365
     * @return true if it is a multi-value placeholder, false otherwise
366
     */
367
    public static boolean isMultiPlaceholder(String placeholder) {
368
        return QueryTemplate.isMultiPlaceholder(placeholder);
9✔
369
    }
370

371
    /**
372
     * Checks whether the given placeholder is an IRI placeholder.
373
     *
374
     * @param placeholder The placeholder name
375
     * @return true if it is an IRI placeholder, false otherwise
376
     */
377
    public static boolean isIriPlaceholder(String placeholder) {
378
        return QueryTemplate.isIriPlaceholder(placeholder);
9✔
379
    }
380

381
    /**
382
     * Returns the parameter name for the given placeholder.
383
     *
384
     * @param placeholder The placeholder name
385
     * @return The parameter name
386
     */
387
    public static String getParamName(String placeholder) {
388
        return QueryTemplate.getParamName(placeholder);
9✔
389
    }
390

391
    /**
392
     * Serializes an IRI string for SPARQL.
393
     *
394
     * @param iriString The IRI string
395
     * @return The serialized IRI
396
     */
397
    public static String serializeIri(String iriString) {
398
        return QueryTemplate.serializeIri(iriString);
9✔
399
    }
400

401
    /**
402
     * Serializes a literal string for SPARQL.
403
     *
404
     * @param literalString The literal string
405
     * @return The serialized literal
406
     */
407
    public static String serializeLiteral(String literalString) {
408
        return QueryTemplate.serializeLiteral(literalString);
9✔
409
    }
410

411
    /**
412
     * Resolves the latest version of a nanopub by following the supersedes chain in the local store.
413
     * Uses a single SPARQL query with a property path to find the latest non-invalidated version
414
     * signed by the same key. If no result is found locally, or if the local store is unavailable,
415
     * returns the original URI.
416
     *
417
     * @param nanopubUri the URI of the nanopub to resolve
418
     * @return the URI of the latest version
419
     */
420
    static String getLatestVersionIdLocally(String nanopubUri) {
421
        logger.info("Resolving latest version locally for: {}", nanopubUri);
12✔
422
        try {
423
            RepositoryConnection conn = TripleStore.get().getRepoConnection("meta");
×
424
            try (conn) {
×
425
                String query =
×
426
                        "SELECT ?latest ?date WHERE { " +
427
                        "GRAPH <" + NPA.GRAPH + "> { " +
428
                        "<" + nanopubUri + "> <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY + "> ?pubkey . " +
429
                        "?latest <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY + "> ?pubkey . " +
430
                        "FILTER NOT EXISTS { ?npx <" + NPX.INVALIDATES + "> ?latest ; " +
431
                        "<" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY + "> ?pubkey . } " +
432
                        "?latest <" + DCTERMS.CREATED + "> ?date . " +
433
                        "} " +
434
                        "GRAPH <" + NPA.NETWORK_GRAPH + "> { " +
435
                        "?latest (<" + NPX.SUPERSEDES + ">)* <" + nanopubUri + "> . " +
436
                        "} " +
437
                        "} ORDER BY DESC(?date) LIMIT 1";
438
                TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate();
×
439
                try (r) {
×
440
                    if (r.hasNext()) {
×
441
                        String latestUri = r.next().getBinding("latest").getValue().stringValue();
×
442
                        logger.info("Resolved latest version: {}", latestUri);
×
443
                        return latestUri;
×
444
                    }
445
                }
×
446
                logger.info("No latest version found locally for: {}", nanopubUri);
×
447
                return nanopubUri;
×
448
            }
×
449
        } catch (Exception ex) {
3✔
450
            logger.warn("Could not resolve latest version locally, using original version: {}", ex.getMessage());
15✔
451
            return nanopubUri;
6✔
452
        }
453
    }
454

455
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc