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

knowledgepixels / nanopub-query / 23632443330

27 Mar 2026 05:20AM UTC coverage: 66.744% (-0.3%) from 67.082%
23632443330

Pull #61

github

web-flow
Merge 59686a478 into 1927e9847
Pull Request #61: feat: preview unpublished nanopub queries via URL parameter

228 of 376 branches covered (60.64%)

Branch coverage included in aggregate %.

637 of 920 relevant lines covered (69.24%)

10.33 hits per line

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

59.85
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
import org.nanopub.vocabulary.NPA;
28
import org.nanopub.vocabulary.NPX;
29
import org.slf4j.Logger;
30
import org.slf4j.LoggerFactory;
31

32
import java.io.ByteArrayInputStream;
33
import java.io.IOException;
34
import java.util.*;
35

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

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

44
    private static final ValueFactory vf = SimpleValueFactory.getInstance();
6✔
45

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

48
    /**
49
     * Exception for invalid grlc specifications.
50
     */
51
    public static class InvalidGrlcSpecException extends Exception {
52

53
        private InvalidGrlcSpecException(String msg) {
54
            super(msg);
9✔
55
        }
3✔
56

57
        private InvalidGrlcSpecException(String msg, Throwable throwable) {
58
            super(msg, throwable);
×
59
        }
×
60

61
    }
62

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

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

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

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

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

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

149
        if (!queryPart.isEmpty() && !queryPart.equals(queryName)) {
30✔
150
            throw new InvalidGrlcSpecException("Query part doesn't match query name: " + queryPart + " / " + queryName);
27✔
151
        }
152

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

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

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

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

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

241
    /**
242
     * Returns the nanopub.
243
     *
244
     * @return the nanopub
245
     */
246
    public Nanopub getNanopub() {
247
        return np;
9✔
248
    }
249

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

259
    /**
260
     * Returns the label.
261
     *
262
     * @return the label
263
     */
264
    public String getLabel() {
265
        return label;
9✔
266
    }
267

268
    /**
269
     * Returns the description.
270
     *
271
     * @return the description
272
     */
273
    public String getDescription() {
274
        return desc;
9✔
275
    }
276

277
    /**
278
     * Returns the query name.
279
     *
280
     * @return the query name
281
     */
282
    public String getQueryName() {
283
        return queryName;
9✔
284
    }
285

286
    /**
287
     * Returns the list of placeholders.
288
     *
289
     * @return the list of placeholders
290
     */
291
    public List<String> getPlaceholdersList() {
292
        return placeholdersList;
9✔
293
    }
294

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

304
    /**
305
     * Returns the query content.
306
     *
307
     * @return the query content
308
     */
309
    public String getQueryContent() {
310
        return queryContent;
9✔
311
    }
312

313
    public boolean isConstructQuery() {
314
        return isConstructQuery;
9✔
315
    }
316

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

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

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

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

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

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

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

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

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

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

490
}
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