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

knowledgepixels / nanopub-query / 24127353986

08 Apr 2026 09:07AM UTC coverage: 66.922% (-0.08%) from 66.999%
24127353986

push

github

tkuhn
fix: prevent event loop blocking from network I/O and lock contention

- Cache nanopub lookups in GrlcSpec to avoid blocking GetNanopub.get()
  calls on the Vert.x event loop for every API request
- Wrap /, /pubkeys, /types handlers in executeBlocking to prevent
  getRepositoryNames() HTTP calls from blocking the event loop
- Make StatusController.getState() lock-free with volatile fields
- Increase HTTP fetch timeouts from 1s to 10s

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

227 of 378 branches covered (60.05%)

Branch coverage included in aggregate %.

645 of 925 relevant lines covered (69.73%)

10.15 hits per line

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

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

3
import com.knowledgepixels.query.vocabulary.KPXL_GRLC;
4
import io.vertx.core.MultiMap;
5
import net.trustyuri.TrustyUriUtils;
6
import org.eclipse.rdf4j.model.IRI;
7
import org.eclipse.rdf4j.model.Statement;
8
import org.eclipse.rdf4j.model.ValueFactory;
9
import org.eclipse.rdf4j.model.impl.SimpleValueFactory;
10
import org.eclipse.rdf4j.model.vocabulary.DCTERMS;
11
import org.eclipse.rdf4j.model.vocabulary.RDFS;
12
import org.eclipse.rdf4j.query.MalformedQueryException;
13
import org.eclipse.rdf4j.query.QueryLanguage;
14
import org.eclipse.rdf4j.query.TupleQueryResult;
15
import org.eclipse.rdf4j.query.algebra.Var;
16
import org.eclipse.rdf4j.query.algebra.helpers.AbstractSimpleQueryModelVisitor;
17
import org.eclipse.rdf4j.query.parser.ParsedGraphQuery;
18
import org.eclipse.rdf4j.query.parser.ParsedQuery;
19
import org.eclipse.rdf4j.query.parser.sparql.SPARQLParser;
20
import org.eclipse.rdf4j.rio.RDFFormat;
21
import org.nanopub.MalformedNanopubException;
22
import org.eclipse.rdf4j.repository.RepositoryConnection;
23
import org.nanopub.Nanopub;
24
import org.nanopub.NanopubImpl;
25
import org.nanopub.SimpleCreatorPattern;
26
import org.nanopub.extra.server.GetNanopub;
27

28
import java.util.concurrent.ConcurrentHashMap;
29
import org.nanopub.vocabulary.NPA;
30
import org.nanopub.vocabulary.NPX;
31
import org.slf4j.Logger;
32
import org.slf4j.LoggerFactory;
33

34
import java.io.ByteArrayInputStream;
35
import java.io.IOException;
36
import java.util.*;
37

38
//TODO merge this class with GrlcQuery of Nanodash and move to a library like nanopub-java
39

40
/**
41
 * This class produces a page with the grlc specification. This is needed internally to tell grlc
42
 * how to execute a particular query template.
43
 */
44
public class GrlcSpec {
45

46
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
47

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

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

52
    /**
53
     * Exception for invalid grlc specifications.
54
     */
55
    public static class InvalidGrlcSpecException extends Exception {
56

57
        private InvalidGrlcSpecException(String msg) {
58
            super(msg);
9✔
59
        }
3✔
60

61
        private InvalidGrlcSpecException(String msg, Throwable throwable) {
62
            super(msg, throwable);
×
63
        }
×
64

65
    }
66

67
    /**
68
     * URL for the given Nanopub Query instance, needed for internal coordination.
69
     */
70
    public static final String nanopubQueryUrl = Utils.getEnvString("NANOPUB_QUERY_URL", "http://query:9393/");
15✔
71

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

74
    private MultiMap parameters;
75
    private Nanopub np;
76
    private String requestUrlBase;
77
    private String artifactCode;
78
    private String queryPart;
79
    private String queryName;
80
    private String label;
81
    private String desc;
82
    private String license;
83
    private String queryContent;
84
    private String endpoint;
85
    private List<String> placeholdersList;
86
    private boolean isConstructQuery;
87

88
    /**
89
     * Creates a new page instance.
90
     *
91
     * @param requestUrl The request URL
92
     * @param parameters The URL request parameters
93
     */
94
    public GrlcSpec(String requestUrl, MultiMap parameters) throws InvalidGrlcSpecException {
6✔
95
        this.parameters = parameters;
9✔
96
        requestUrl = requestUrl.replaceFirst("\\?.*$", "");
15✔
97
        if (!requestUrl.matches(".*/RA[A-Za-z0-9\\-_]{43}/(.*)?")) {
12✔
98
            throw new InvalidGrlcSpecException("Invalid grlc API request: " + requestUrl);
18✔
99
        }
100
        artifactCode = requestUrl.replaceFirst("^(.*/)(RA[A-Za-z0-9\\-_]{43})/(.*)?$", "$2");
18✔
101
        requestUrlBase = requestUrl.replaceFirst("^/(.*/)(RA[A-Za-z0-9\\-_]{43})/(.*)?$", "$1");
18✔
102

103
        queryPart = requestUrl.replaceFirst("^(.*/)(RA[A-Za-z0-9\\-_]{43}/)(.*)?$", "$3");
18✔
104
        queryPart = queryPart.replaceFirst(".rq$", "");
21✔
105

106
        String nanopubParam = parameters.get("_nanopub_trig");
12✔
107
        if (nanopubParam != null && !nanopubParam.isEmpty()) {
6!
108
            try {
109
                byte[] trig = Base64.getUrlDecoder().decode(nanopubParam);
×
110
                np = new NanopubImpl(new ByteArrayInputStream(trig), RDFFormat.TRIG);
×
111
            } catch (MalformedNanopubException | IOException | IllegalArgumentException ex) {
×
112
                throw new InvalidGrlcSpecException("Failed to parse nanopub from 'nanopub' parameter", ex);
×
113
            }
×
114
        } else {
115
            np = nanopubCache.computeIfAbsent(artifactCode, GetNanopub::get);
24✔
116
        }
117
        // TODO rename "api-version" to "_api_version" for consistency
118
        if (parameters.get("api-version") != null && parameters.get("api-version").equals("latest")) {
30✔
119
            String latestUri = getLatestVersionIdLocally(np.getUri().stringValue());
18✔
120
            if (!latestUri.equals(np.getUri().stringValue())) {
21!
121
                np = nanopubCache.computeIfAbsent(TrustyUriUtils.getArtifactCode(latestUri), GetNanopub::get);
×
122
            }
123
            artifactCode = TrustyUriUtils.getArtifactCode(np.getUri().stringValue());
21✔
124
        }
125
        for (Statement st : np.getAssertion()) {
36✔
126
            if (!st.getSubject().stringValue().startsWith(np.getUri().stringValue())) {
27!
127
                continue;
×
128
            }
129
            String qn = st.getSubject().stringValue().replaceFirst("^.*[#/](.*)$", "$1");
21✔
130
            if (queryName != null && !qn.equals(queryName)) {
24!
131
                throw new InvalidGrlcSpecException("Subject suffixes don't match: " + queryName);
×
132
            }
133
            queryName = qn;
9✔
134
            if (st.getPredicate().equals(RDFS.LABEL)) {
15✔
135
                label = st.getObject().stringValue();
18✔
136
            } else if (st.getPredicate().equals(DCTERMS.DESCRIPTION)) {
15✔
137
                desc = st.getObject().stringValue();
18✔
138
            } else if (st.getPredicate().equals(DCTERMS.LICENSE) && st.getObject() instanceof IRI) {
27!
139
                license = st.getObject().stringValue();
18✔
140
            } else if (st.getPredicate().equals(KPXL_GRLC.SPARQL)) {
15✔
141
                // TODO Improve this:
142
                queryContent = st.getObject().stringValue().replace(NANOPUB_QUERY_REPO_URL, nanopubQueryUrl + "repo/");
30✔
143
            } else if (st.getPredicate().equals(KPXL_GRLC.ENDPOINT) && st.getObject() instanceof IRI) {
27!
144
                endpoint = st.getObject().stringValue();
15✔
145
                if (endpoint.startsWith(NANOPUB_QUERY_REPO_URL)) {
15!
146
                    endpoint = endpoint.replace(NANOPUB_QUERY_REPO_URL, nanopubQueryUrl + "repo/");
27✔
147
                } else {
148
                    throw new InvalidGrlcSpecException("Invalid/non-recognized endpoint: " + endpoint);
×
149
                }
150
            }
151
        }
3✔
152

153
        if (!queryPart.isEmpty() && !queryPart.equals(queryName)) {
30✔
154
            throw new InvalidGrlcSpecException("Query part doesn't match query name: " + queryPart + " / " + queryName);
27✔
155
        }
156

157
        final Set<String> placeholders = new HashSet<>();
12✔
158
        try {
159
            ParsedQuery query = new SPARQLParser().parseQuery(queryContent, null);
24✔
160
            isConstructQuery = query instanceof ParsedGraphQuery;
12✔
161
            query.getTupleExpr().visitChildren(new AbstractSimpleQueryModelVisitor<>() {
42✔
162

163
                @Override
164
                public void meet(Var node) throws RuntimeException {
165
                    super.meet(node);
9✔
166
                    if (!node.isConstant() && !node.isAnonymous() && node.getName().startsWith("_")) {
33!
167
                        placeholders.add(node.getName());
×
168
                    }
169
                }
3✔
170

171
            });
172
        } catch (MalformedQueryException ex) {
×
173
            throw new InvalidGrlcSpecException("Invalid SPARQL string", ex);
×
174
        }
3✔
175
        List<String> placeholdersListPre = new ArrayList<>(placeholders);
15✔
176
        Collections.sort(placeholdersListPre);
6✔
177
        placeholdersListPre.sort(Comparator.comparing(String::length));
12✔
178
        placeholdersList = Collections.unmodifiableList(placeholdersListPre);
12✔
179
    }
3✔
180

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

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

245
    /**
246
     * Returns the nanopub.
247
     *
248
     * @return the nanopub
249
     */
250
    public Nanopub getNanopub() {
251
        return np;
9✔
252
    }
253

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

263
    /**
264
     * Returns the label.
265
     *
266
     * @return the label
267
     */
268
    public String getLabel() {
269
        return label;
9✔
270
    }
271

272
    /**
273
     * Returns the description.
274
     *
275
     * @return the description
276
     */
277
    public String getDescription() {
278
        return desc;
9✔
279
    }
280

281
    /**
282
     * Returns the query name.
283
     *
284
     * @return the query name
285
     */
286
    public String getQueryName() {
287
        return queryName;
9✔
288
    }
289

290
    /**
291
     * Returns the list of placeholders.
292
     *
293
     * @return the list of placeholders
294
     */
295
    public List<String> getPlaceholdersList() {
296
        return placeholdersList;
9✔
297
    }
298

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

308
    /**
309
     * Returns the query content.
310
     *
311
     * @return the query content
312
     */
313
    public String getQueryContent() {
314
        return queryContent;
9✔
315
    }
316

317
    public boolean isConstructQuery() {
318
        return isConstructQuery;
9✔
319
    }
320

321
    /**
322
     * Expands the query by replacing the placeholders with the provided parameter values.
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
        String expandedQueryContent = queryContent;
×
329
        logger.info("Expanding grlc query with parameters: {}", parameters);
×
330
        for (String ph : placeholdersList) {
×
331
            logger.info("Processing placeholder <{}> associated to parameter with name <{}>", ph, getParamName(ph));
×
332
            if (isMultiPlaceholder(ph)) {
×
333
                // TODO multi placeholders need proper documentation
334
                List<String> val = parameters.getAll(getParamName(ph));
×
335
                if (!isOptionalPlaceholder(ph) && val.isEmpty()) {
×
336
                    throw new InvalidGrlcSpecException("Missing value for non-optional placeholder: " + ph);
×
337
                }
338
                if (val.isEmpty()) {
×
339
                    expandedQueryContent = expandedQueryContent.replaceAll("values\\s*\\?" + ph + "\\s*\\{\\s*\\}(\\s*\\.)?", "");
×
340
                    continue;
×
341
                }
342
                String valueList = "";
×
343
                for (String v : val) {
×
344
                    if (isIriPlaceholder(ph)) {
×
345
                        valueList += serializeIri(v) + " ";
×
346
                    } else {
347
                        valueList += serializeLiteral(v) + " ";
×
348
                    }
349
                }
×
350
                expandedQueryContent = expandedQueryContent.replaceAll("values\\s*\\?" + ph + "\\s*\\{\\s*\\}", "values ?" + ph + " { " + escapeSlashes(valueList) + "}");
×
351
            } else {
×
352
                String val = parameters.get(getParamName(ph));
×
353
                logger.info("Value for placeholder <{}>: {}", ph, val);
×
354
                if (!isOptionalPlaceholder(ph) && val == null) {
×
355
                    throw new InvalidGrlcSpecException("Missing value for non-optional placeholder: " + ph);
×
356
                }
357
                if (val == null) {
×
358
                    continue;
×
359
                }
360
                if (isIriPlaceholder(ph)) {
×
361
                    expandedQueryContent = expandedQueryContent.replaceAll("\\?" + ph, escapeSlashes(serializeIri(val)));
×
362
                } else {
363
                    expandedQueryContent = expandedQueryContent.replaceAll("\\?" + ph, escapeSlashes(serializeLiteral(val)));
×
364
                }
365
            }
366
        }
×
367
        return expandedQueryContent;
×
368
    }
369

370
    /**
371
     * Escapes a literal string for SPARQL.
372
     *
373
     * @param s The string
374
     * @return The escaped string
375
     */
376
    public static String escapeLiteral(String s) {
377
        return s.replace("\\", "\\\\").replace("\n", "\\n").replace("\"", "\\\"");
33✔
378
    }
379

380
    /**
381
     * Checks whether the given placeholder is an optional placeholder.
382
     *
383
     * @param placeholder The placeholder name
384
     * @return true if it is an optional placeholder, false otherwise
385
     */
386
    public static boolean isOptionalPlaceholder(String placeholder) {
387
        return placeholder.startsWith("__");
12✔
388
    }
389

390
    /**
391
     * Checks whether the given placeholder is a multi-value placeholder.
392
     *
393
     * @param placeholder The placeholder name
394
     * @return true if it is a multi-value placeholder, false otherwise
395
     */
396
    public static boolean isMultiPlaceholder(String placeholder) {
397
        return placeholder.endsWith("_multi") || placeholder.endsWith("_multi_iri");
36✔
398
    }
399

400
    /**
401
     * Checks whether the given placeholder is an IRI placeholder.
402
     *
403
     * @param placeholder The placeholder name
404
     * @return true if it is an IRI placeholder, false otherwise
405
     */
406
    public static boolean isIriPlaceholder(String placeholder) {
407
        return placeholder.endsWith("_iri");
12✔
408
    }
409

410
    /**
411
     * Returns the parameter name for the given placeholder.
412
     *
413
     * @param placeholder The placeholder name
414
     * @return The parameter name
415
     */
416
    public static String getParamName(String placeholder) {
417
        return placeholder.replaceFirst("^_+", "").replaceFirst("_iri$", "").replaceFirst("_multi$", "");
33✔
418
    }
419

420
    /**
421
     * Serializes an IRI string for SPARQL.
422
     *
423
     * @param iriString The IRI string
424
     * @return The serialized IRI
425
     */
426
    public static String serializeIri(String iriString) {
427
        return "<" + iriString + ">";
9✔
428
    }
429

430
    /**
431
     * Escapes slashes in a string.
432
     *
433
     * @param string The string
434
     * @return The escaped string
435
     */
436
    private static String escapeSlashes(String string) {
437
        return string.replace("\\", "\\\\");
×
438
    }
439

440
    /**
441
     * Serializes a literal string for SPARQL.
442
     *
443
     * @param literalString The literal string
444
     * @return The serialized literal
445
     */
446
    public static String serializeLiteral(String literalString) {
447
        return "\"" + escapeLiteral(literalString) + "\"";
12✔
448
    }
449

450
    /**
451
     * Resolves the latest version of a nanopub by following the supersedes chain in the local store.
452
     * Uses a single SPARQL query with a property path to find the latest non-invalidated version
453
     * signed by the same key. If no result is found locally, or if the local store is unavailable,
454
     * returns the original URI.
455
     *
456
     * @param nanopubUri the URI of the nanopub to resolve
457
     * @return the URI of the latest version
458
     */
459
    static String getLatestVersionIdLocally(String nanopubUri) {
460
        logger.info("Resolving latest version locally for: {}", nanopubUri);
12✔
461
        try {
462
            RepositoryConnection conn = TripleStore.get().getRepoConnection("meta");
×
463
            try (conn) {
×
464
                String query =
×
465
                        "SELECT ?latest ?date WHERE { " +
466
                        "GRAPH <" + NPA.GRAPH + "> { " +
467
                        "<" + nanopubUri + "> <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY + "> ?pubkey . " +
468
                        "?latest <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY + "> ?pubkey . " +
469
                        "FILTER NOT EXISTS { ?npx <" + NPX.INVALIDATES + "> ?latest ; " +
470
                        "<" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY + "> ?pubkey . } " +
471
                        "?latest <" + DCTERMS.CREATED + "> ?date . " +
472
                        "} " +
473
                        "GRAPH <" + NPA.NETWORK_GRAPH + "> { " +
474
                        "?latest (<" + NPX.SUPERSEDES + ">)* <" + nanopubUri + "> . " +
475
                        "} " +
476
                        "} ORDER BY DESC(?date) LIMIT 1";
477
                TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate();
×
478
                try (r) {
×
479
                    if (r.hasNext()) {
×
480
                        String latestUri = r.next().getBinding("latest").getValue().stringValue();
×
481
                        logger.info("Resolved latest version: {}", latestUri);
×
482
                        return latestUri;
×
483
                    }
484
                }
×
485
                logger.info("No latest version found locally for: {}", nanopubUri);
×
486
                return nanopubUri;
×
487
            }
×
488
        } catch (Exception ex) {
3✔
489
            logger.warn("Could not resolve latest version locally, using original version: {}", ex.getMessage());
15✔
490
            return nanopubUri;
6✔
491
        }
492
    }
493

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

© 2026 Coveralls, Inc