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

knowledgepixels / nanopub-query / 26363907074

24 May 2026 02:28PM UTC coverage: 59.543% (+0.8%) from 58.782%
26363907074

Pull #109

github

web-flow
Merge c7b82a8e1 into 559f767ae
Pull Request #109: refactor(GrlcSpec): delegate to QueryTemplate (nanopub-java 1.89.0)

470 of 880 branches covered (53.41%)

Branch coverage included in aggregate %.

1355 of 2185 relevant lines covered (62.01%)

9.3 hits per line

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

68.11
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_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
     * URL for the given Nanopub Query instance, needed for internal coordination.
72
     */
73
    public static final String nanopubQueryUrl = Utils.getEnvString("NANOPUB_QUERY_URL", "http://query:9393/");
15✔
74

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

77
    private final MultiMap parameters;
78
    private final QueryTemplate template;
79
    private final String requestUrlBase;
80
    private final String artifactCode;
81
    private final String queryPart;
82
    private final String queryContent;
83
    private final String endpoint;
84

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

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

104
        Nanopub np;
105
        String nanopubParam = parameters.get("_nanopub_trig");
12✔
106
        if (nanopubParam != null && !nanopubParam.isEmpty()) {
6!
107
            try {
108
                byte[] trig = Base64.getUrlDecoder().decode(nanopubParam);
×
109
                np = new NanopubImpl(new ByteArrayInputStream(trig), RDFFormat.TRIG);
×
110
            } catch (MalformedNanopubException | IOException | IllegalArgumentException ex) {
×
111
                throw new InvalidGrlcSpecException("Failed to parse nanopub from 'nanopub' parameter", ex);
×
112
            }
×
113
        } else {
114
            np = nanopubCache.computeIfAbsent(parsedArtifactCode, GetNanopub::get);
18✔
115
        }
116
        // TODO rename "api-version" to "_api_version" for consistency
117
        if (parameters.get("api-version") != null && parameters.get("api-version").equals("latest")) {
30✔
118
            String latestUri = getLatestVersionIdLocally(np.getUri().stringValue());
15✔
119
            if (!latestUri.equals(np.getUri().stringValue())) {
18!
120
                np = nanopubCache.computeIfAbsent(TrustyUriUtils.getArtifactCode(latestUri), GetNanopub::get);
×
121
            }
122
            parsedArtifactCode = TrustyUriUtils.getArtifactCode(np.getUri().stringValue());
15✔
123
        }
124
        artifactCode = parsedArtifactCode;
9✔
125

126
        try {
127
            if (queryPart.isEmpty()) {
12✔
128
                template = new QueryTemplate(np);
21✔
129
            } else {
130
                template = new QueryTemplate(np, artifactCode + "/" + queryPart);
33✔
131
            }
132
        } catch (IllegalArgumentException ex) {
3✔
133
            throw new InvalidGrlcSpecException(ex.getMessage(), ex);
21✔
134
        }
3✔
135

136
        if (!queryPart.isEmpty() && !queryPart.equals(template.getQuerySuffix())) {
33!
137
            throw new InvalidGrlcSpecException(
×
138
                    "Query part doesn't match query name: " + queryPart + " / " + template.getQuerySuffix());
×
139
        }
140

141
        queryContent = template.getSparql().replace(NANOPUB_QUERY_REPO_URL, nanopubQueryUrl + "repo/");
27✔
142

143
        IRI rawEndpoint = template.getEndpoint();
12✔
144
        if (rawEndpoint != null) {
6!
145
            String ep = rawEndpoint.stringValue();
9✔
146
            if (!ep.startsWith(NANOPUB_QUERY_REPO_URL)) {
12!
147
                throw new InvalidGrlcSpecException("Invalid/non-recognized endpoint: " + ep);
×
148
            }
149
            endpoint = ep.replace(NANOPUB_QUERY_REPO_URL, nanopubQueryUrl + "repo/");
21✔
150
        } else {
3✔
151
            endpoint = null;
×
152
        }
153
    }
3✔
154

155
    /**
156
     * Returns the grlc spec as a string.
157
     *
158
     * @return grlc specification string
159
     */
160
    public String getSpec() {
161
        String s = "";
6✔
162
        String label = template.getLabel();
12✔
163
        String desc = template.getDescription();
12✔
164
        IRI license = template.getLicense();
12✔
165
        String queryName = template.getQuerySuffix();
12✔
166
        if (queryPart.isEmpty()) {
12✔
167
            if (label == null) {
6!
168
                s += "title: \"untitled query\"\n";
×
169
            } else {
170
                s += "title: \"" + QueryTemplate.escapeLiteral(label) + "\"\n";
15✔
171
            }
172
            s += "description: \"" + QueryTemplate.escapeLiteral(desc) + "\"\n";
15✔
173
            StringBuilder userName = new StringBuilder();
12✔
174
            Set<IRI> creators = SimpleCreatorPattern.getCreators(template.getNanopub());
15✔
175
            for (IRI userIri : creators) {
30✔
176
                userName.append(", ").append(userIri);
18✔
177
            }
3✔
178
            if (!userName.isEmpty()) {
9!
179
                userName = new StringBuilder(userName.substring(2));
21✔
180
            }
181
            String url = "";
6✔
182
            if (!creators.isEmpty()) {
9!
183
                url = creators.iterator().next().stringValue();
18✔
184
            }
185
            s += "contact:\n";
9✔
186
            s += "  name: \"" + QueryTemplate.escapeLiteral(userName.toString()) + "\"\n";
18✔
187
            s += "  url: " + url + "\n";
12✔
188
            if (license != null) {
6!
189
                s += "licence: " + license.stringValue() + "\n";
15✔
190
            }
191
            s += "queries:\n";
9✔
192
            s += "  - " + nanopubQueryUrl + requestUrlBase + artifactCode + "/" + queryName + ".rq";
27✔
193
        } else if (queryPart.equals(queryName)) {
18!
194
            if (label != null) {
6!
195
                s += "#+ summary: \"" + QueryTemplate.escapeLiteral(label) + "\"\n";
15✔
196
            }
197
            if (desc != null) {
6!
198
                s += "#+ description: \"" + QueryTemplate.escapeLiteral(desc) + "\"\n";
15✔
199
            }
200
            if (license != null) {
6!
201
                s += "#+ licence: " + license.stringValue() + "\n";
15✔
202
            }
203
            if (endpoint != null) {
9!
204
                s += "#+ endpoint: " + endpoint + "\n";
15✔
205
            }
206
            s += "\n";
9✔
207
            s += queryContent;
18✔
208
        } else {
209
            throw new RuntimeException("Unexpected queryPart: " + queryPart);
×
210
        }
211
        return s;
6✔
212
    }
213

214
    /**
215
     * Returns the request parameters.
216
     *
217
     * @return the request parameters
218
     */
219
    public MultiMap getParameters() {
220
        return parameters;
9✔
221
    }
222

223
    /**
224
     * Returns the nanopub.
225
     *
226
     * @return the nanopub
227
     */
228
    public Nanopub getNanopub() {
229
        return template.getNanopub();
12✔
230
    }
231

232
    /**
233
     * Returns the artifact code.
234
     *
235
     * @return the artifact code
236
     */
237
    public String getArtifactCode() {
238
        return artifactCode;
9✔
239
    }
240

241
    /**
242
     * Returns the label.
243
     *
244
     * @return the label
245
     */
246
    public String getLabel() {
247
        return template.getLabel();
12✔
248
    }
249

250
    /**
251
     * Returns the description.
252
     *
253
     * @return the description
254
     */
255
    public String getDescription() {
256
        return template.getDescription();
12✔
257
    }
258

259
    /**
260
     * Returns the query name.
261
     *
262
     * @return the query name
263
     */
264
    public String getQueryName() {
265
        return template.getQuerySuffix();
12✔
266
    }
267

268
    /**
269
     * Returns the list of placeholders.
270
     *
271
     * @return the list of placeholders
272
     */
273
    public List<String> getPlaceholdersList() {
274
        return template.getPlaceholdersList();
12✔
275
    }
276

277
    /**
278
     * Returns the repository name derived from the endpoint URL.
279
     *
280
     * @return the repository name
281
     */
282
    public String getRepoName() {
283
        return endpoint.replaceAll("/", "_").replaceFirst("^.*_repo_", "");
27✔
284
    }
285

286
    /**
287
     * Returns the query content (with the canonical repo URL rewritten to the
288
     * in-cluster {@link #nanopubQueryUrl}{@code /repo/}).
289
     *
290
     * @return the query content
291
     */
292
    public String getQueryContent() {
293
        return queryContent;
9✔
294
    }
295

296
    public boolean isConstructQuery() {
297
        return template.isConstructQuery();
12✔
298
    }
299

300
    /**
301
     * Expands the query by replacing the placeholders with the provided parameter
302
     * values, and rewrites the canonical repo URL to the in-cluster one.
303
     *
304
     * @return the expanded query
305
     * @throws InvalidGrlcSpecException if a non-optional placeholder is missing a value
306
     */
307
    public String expandQuery() throws InvalidGrlcSpecException {
308
        Map<String, List<String>> params = new LinkedHashMap<>();
×
309
        for (String name : parameters.names()) {
×
310
            params.put(name, new ArrayList<>(parameters.getAll(name)));
×
311
        }
×
312
        logger.info("Expanding grlc query with parameters: {}", parameters);
×
313
        try {
314
            String expanded = template.expandQuery(params);
×
315
            return expanded.replace(NANOPUB_QUERY_REPO_URL, nanopubQueryUrl + "repo/");
×
316
        } catch (IllegalArgumentException ex) {
×
317
            throw new InvalidGrlcSpecException(ex.getMessage(), ex);
×
318
        }
319
    }
320

321
    /**
322
     * Escapes a literal string for SPARQL.
323
     *
324
     * @param s The string
325
     * @return The escaped string
326
     */
327
    public static String escapeLiteral(String s) {
328
        return QueryTemplate.escapeLiteral(s);
×
329
    }
330

331
    /**
332
     * Checks whether the given placeholder is an optional placeholder.
333
     *
334
     * @param placeholder The placeholder name
335
     * @return true if it is an optional placeholder, false otherwise
336
     */
337
    public static boolean isOptionalPlaceholder(String placeholder) {
338
        return QueryTemplate.isOptionalPlaceholder(placeholder);
9✔
339
    }
340

341
    /**
342
     * Checks whether the given placeholder is a multi-value placeholder.
343
     *
344
     * @param placeholder The placeholder name
345
     * @return true if it is a multi-value placeholder, false otherwise
346
     */
347
    public static boolean isMultiPlaceholder(String placeholder) {
348
        return QueryTemplate.isMultiPlaceholder(placeholder);
9✔
349
    }
350

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

361
    /**
362
     * Returns the parameter name for the given placeholder.
363
     *
364
     * @param placeholder The placeholder name
365
     * @return The parameter name
366
     */
367
    public static String getParamName(String placeholder) {
368
        return QueryTemplate.getParamName(placeholder);
9✔
369
    }
370

371
    /**
372
     * Serializes an IRI string for SPARQL.
373
     *
374
     * @param iriString The IRI string
375
     * @return The serialized IRI
376
     */
377
    public static String serializeIri(String iriString) {
378
        return QueryTemplate.serializeIri(iriString);
9✔
379
    }
380

381
    /**
382
     * Serializes a literal string for SPARQL.
383
     *
384
     * @param literalString The literal string
385
     * @return The serialized literal
386
     */
387
    public static String serializeLiteral(String literalString) {
388
        return QueryTemplate.serializeLiteral(literalString);
9✔
389
    }
390

391
    /**
392
     * Resolves the latest version of a nanopub by following the supersedes chain in the local store.
393
     * Uses a single SPARQL query with a property path to find the latest non-invalidated version
394
     * signed by the same key. If no result is found locally, or if the local store is unavailable,
395
     * returns the original URI.
396
     *
397
     * @param nanopubUri the URI of the nanopub to resolve
398
     * @return the URI of the latest version
399
     */
400
    static String getLatestVersionIdLocally(String nanopubUri) {
401
        logger.info("Resolving latest version locally for: {}", nanopubUri);
12✔
402
        try {
403
            RepositoryConnection conn = TripleStore.get().getRepoConnection("meta");
×
404
            try (conn) {
×
405
                String query =
×
406
                        "SELECT ?latest ?date WHERE { " +
407
                        "GRAPH <" + NPA.GRAPH + "> { " +
408
                        "<" + nanopubUri + "> <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY + "> ?pubkey . " +
409
                        "?latest <" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY + "> ?pubkey . " +
410
                        "FILTER NOT EXISTS { ?npx <" + NPX.INVALIDATES + "> ?latest ; " +
411
                        "<" + NPA.HAS_VALID_SIGNATURE_FOR_PUBLIC_KEY + "> ?pubkey . } " +
412
                        "?latest <" + DCTERMS.CREATED + "> ?date . " +
413
                        "} " +
414
                        "GRAPH <" + NPA.NETWORK_GRAPH + "> { " +
415
                        "?latest (<" + NPX.SUPERSEDES + ">)* <" + nanopubUri + "> . " +
416
                        "} " +
417
                        "} ORDER BY DESC(?date) LIMIT 1";
418
                TupleQueryResult r = conn.prepareTupleQuery(QueryLanguage.SPARQL, query).evaluate();
×
419
                try (r) {
×
420
                    if (r.hasNext()) {
×
421
                        String latestUri = r.next().getBinding("latest").getValue().stringValue();
×
422
                        logger.info("Resolved latest version: {}", latestUri);
×
423
                        return latestUri;
×
424
                    }
425
                }
×
426
                logger.info("No latest version found locally for: {}", nanopubUri);
×
427
                return nanopubUri;
×
428
            }
×
429
        } catch (Exception ex) {
3✔
430
            logger.warn("Could not resolve latest version locally, using original version: {}", ex.getMessage());
15✔
431
            return nanopubUri;
6✔
432
        }
433
    }
434

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