ftr_ruby 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: def2bc2018d1d794afabd625f72f2bc5f78fb363dc4a06f87c2fd8cc2967f7e2
4
+ data.tar.gz: 6ae0b03ff20569a405c6f34f5e13fd84f6a9bfe9db9d6b67f536ca2cadb4d5e2
5
+ SHA512:
6
+ metadata.gz: 1f8771dfbcb93530f56ad05f53f2606300f4d829e78d66976f971501bc62d39668eeb5afe67ad0db00adb56dd263964f4d934afc64359285ce889b8389279738
7
+ data.tar.gz: 13eaaa05362c0290c13377d1b7f7b2e427bc7e00caa5d83a082e02eac6c404ee4d7f41fcfad9486a5a6e527af57487a2156f9a790240701314b4ff957f434abe
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2026-03-28
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 markwilkinson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,179 @@
1
+ # FtrRuby
2
+
3
+ **Ruby library for generating DCAT-compliant metadata and FAIR test outputs following the FTR Vocabulary**
4
+
5
+ `FtrRuby` provides two main classes for working with FAIR Tests in Ruby:
6
+
7
+ - `DCAT_Record` – Creates rich DCAT metadata describing a FAIR Test (as a `dcat:DataService` + `ftr:Test`)
8
+ - `Output` – Generates a standardized FAIR test execution result (as a `ftr:TestResult` with provenance)
9
+
10
+ The library uses the **TripleEasy** mixin for easy RDF triple creation and produces graphs compatible with DCAT, DQV, PROV, and the **FAIR Test Registry (FTR)** vocabulary.
11
+
12
+ ---
13
+
14
+ ## Features
15
+
16
+ - Full DCAT 2 metadata generation for FAIR Tests
17
+ - Standardized test result output with provenance (`prov:wasGeneratedBy`, `ftr:TestResult`, etc.)
18
+ - Automatic URL construction for test endpoints and identifiers
19
+ - Support for contact points, indicators, metrics, themes, and guidance
20
+ - JSON-LD output for easy consumption by registries and portals
21
+ - Ready for use in FAIR assessment platforms, OSTrails, and EOSC services
22
+
23
+ ---
24
+
25
+ ## Installation
26
+
27
+ If published as a gem:
28
+
29
+ ```ruby
30
+ gem 'ftr_ruby'
31
+ ```
32
+
33
+ Or install manually:
34
+
35
+ ```bash
36
+ gem install ftr_ruby
37
+ ```
38
+
39
+ For local development:
40
+
41
+ ```ruby
42
+ require_relative 'lib/ftr_ruby'
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Usage
48
+
49
+ ### 1. Documenting a FAIR Test (`DCAT_Record`)
50
+
51
+ ```ruby
52
+ require 'ftr_ruby'
53
+
54
+ meta = {
55
+ testid: "ftr-rda-f1-01m",
56
+ testname: "FAIR Test F1-01M: Globally Unique Persistent Identifier",
57
+ description: "This test checks whether a digital object is identified by a globally unique persistent identifier.",
58
+ keywords: ["FAIR", "F1", "persistent identifier", "PID"],
59
+ creator: "https://orcid.org/0000-0001-2345-6789",
60
+ indicators: ["https://w3id.org/ftr/indicator/F1-01M"],
61
+ metric: "https://w3id.org/ftr/metric/F1-01M",
62
+ license: "https://creativecommons.org/licenses/by/4.0/",
63
+ testversion: "1.0.0",
64
+ protocol: "https",
65
+ host: "tests.ostrails.eu",
66
+ basePath: "api",
67
+ individuals: [{ "name" => "Mark Wilkinson", "email" => "mark.wilkinson@upm.es" }],
68
+ organizations: [{ "name" => "CBGP", "url" => "https://www.cbgp.upm.es" }]
69
+ }
70
+
71
+ record = FtrRuby::DCAT_Record.new(meta: meta)
72
+ graph = record.get_dcat
73
+
74
+ puts graph.dump(:turtle)
75
+ ```
76
+
77
+ ### 2. Generating Test Output (`Output`)
78
+
79
+ ```ruby
80
+ require 'ftr_ruby'
81
+
82
+ # Meta comes from the same test definition used for DCAT_Record
83
+ meta = { ... } # same hash as above
84
+
85
+ output = FtrRuby::Output.new(
86
+ testedGUID: "https://doi.org/10.1234/example.dataset",
87
+ meta: meta
88
+ )
89
+
90
+ # Add test results and comments
91
+ output.score = "pass"
92
+ output.comments << "The resource has a valid persistent identifier."
93
+ output.comments << "Identifier resolves correctly."
94
+
95
+ # Optional: add guidance for non-passing cases
96
+ output.guidance = [
97
+ ["https://example.org/fix-pid", "Register a persistent identifier"],
98
+ ]
99
+
100
+ jsonld = output.createEvaluationResponse
101
+ puts jsonld
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Classes
107
+
108
+ ### `FtrRuby::DCAT_Record`
109
+
110
+ Creates metadata describing the test itself.
111
+
112
+ - Builds a `dcat:DataService` + `ftr:Test`
113
+ - Automatically constructs endpoint URLs, landing pages, and identifiers
114
+ - Includes indicators, metrics, themes, license, contact points, etc.
115
+
116
+ See the class for full list of supported metadata fields.
117
+
118
+ ### `FtrRuby::Output`
119
+
120
+ Represents the result of executing a FAIR test against a specific resource.
121
+
122
+ - Produces a `ftr:TestResult` linked to a `ftr:TestExecutionActivity`
123
+ - Includes score, summary, log/comments, guidance suggestions, and provenance
124
+ - Outputs as JSON-LD (with configurable prefixes)
125
+ - Automatically handles assessment target (the tested GUID)
126
+
127
+ **Key methods:**
128
+
129
+ - `new(testedGUID:, meta:)` – Initialize with the tested resource and test metadata
130
+ - `createEvaluationResponse` – Returns JSON-LD string of the full evaluation graph
131
+
132
+ ---
133
+
134
+ ## Vocabulary & Standards Used
135
+
136
+ - **DCAT** – Data Catalog Vocabulary (W3C)
137
+ - **DQV** – Data Quality Vocabulary
138
+ - **PROV** – Provenance Ontology
139
+ - **FTR** – FAIR Test Registry vocabulary (`https://w3id.org/ftr#`)
140
+ - **SIO** – Semanticscience Integrated Ontology
141
+ - **vCard** – Contact points
142
+ - Schema.org
143
+
144
+ ---
145
+
146
+ ## Project Structure
147
+
148
+ ```
149
+ ftr_ruby/
150
+ ├── lib/
151
+ │ └── ftr_ruby.rb
152
+ ├── lib/ftr_ruby/
153
+ │ ├── dcat_record.rb
154
+ │ └── output.rb
155
+ └── README.md
156
+ ```
157
+
158
+ ---
159
+
160
+ ## Contributing
161
+
162
+ Bug reports and pull requests are welcome on GitHub at:
163
+ https://github.com/markwilkinson/ftr_ruby
164
+
165
+ ---
166
+
167
+ ## License
168
+
169
+ This project is licensed under the [MIT License](LICENSE) (or specify your license).
170
+
171
+ ---
172
+
173
+ ## Acknowledgments
174
+
175
+ Developed in the context of the **OSTrails** project and the **FAIR Champion** initiative.
176
+
177
+ ---
178
+
179
+ **Made with ❤️ for the FAIR community**
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,235 @@
1
+ ##
2
+ # Module containing FAIR Test Registry (FTR) related classes.
3
+ module FtrRuby
4
+ ##
5
+ # Represents a single FAIR Test as a DCAT-compliant DataService with additional
6
+ # FAIR-specific metadata.
7
+ #
8
+ # This class generates RDF metadata (in a DCAT + DQV + FTR vocabulary profile)
9
+ # describing a test that can be used to assess FAIR compliance of digital objects
10
+ # (typically datasets). The resulting graph follows the DCAT-AP style for Data Services,
11
+ # extended with FAIR Test Registry (FTR) semantics.
12
+ #
13
+ # == Usage
14
+ #
15
+ # meta = {
16
+ # testid: "ftr-rda-f1-01m",
17
+ # testname: "FAIR Test F1-01M: Persistent Identifier",
18
+ # description: "Checks whether the digital object has a globally unique persistent identifier...",
19
+ # keywords: ["FAIR", "persistent identifier", "F1"],
20
+ # creator: "https://orcid.org/0000-0001-2345-6789",
21
+ # indicators: ["https://w3id.org/ftr/indicator/F1-01M"],
22
+ # metric: "https://w3id.org/ftr/metric/F1-01M",
23
+ # license: "https://creativecommons.org/licenses/by/4.0/",
24
+ # testversion: "1.0",
25
+ # # ... other fields
26
+ # }
27
+ #
28
+ # record = FtrRuby::DCAT_Record.new(meta: meta)
29
+ # graph = record.get_dcat
30
+ #
31
+ class DCAT_Record
32
+ attr_accessor :identifier, :testname, :description, :keywords, :creator,
33
+ :indicators, :end_desc, :end_url, :dctype, :testid, :supportedby,
34
+ :license, :themes, :testversion, :implementations, :isapplicablefor, :applicationarea,
35
+ :organizations, :individuals, :protocol, :host, :basePath, :metric, :landingpage
36
+
37
+ require_relative "./output"
38
+ include TripleEasy # get the :"triplify" function
39
+ # triplify(s, p, o, repo, datatype: nil, context: nil, language: 'en')
40
+
41
+ ##
42
+ # Creates a new DCAT_Record from metadata hash.
43
+ #
44
+ # @param meta [Hash] Metadata describing the FAIR test.
45
+ # @option meta [String] :testid Unique identifier for the test (used in URLs)
46
+ # @option meta [String] :testname Human-readable name/title of the test
47
+ # @option meta [String] :description Detailed description of what the test does
48
+ # @option meta [String, Array<String>] :keywords Keywords describing the test
49
+ # @option meta [String] :creator URI or literal identifying the creator
50
+ # @option meta [String, Array<String>] :indicators URIs of the FAIR indicators this test addresses
51
+ # @option meta [String] :metric URI of the metric this test implements
52
+ # @option meta [String] :license License URI for the test
53
+ # @option meta [String, Array<String>] :themes Thematic categories (DCAT themes)
54
+ # @option meta [String] :testversion Version of the test
55
+ # @option meta [Array<Hash>] :individuals List of contact individuals (name, email)
56
+ # @option meta [Array<Hash>] :organizations List of responsible organizations (name, url)
57
+ # @option meta [String] :protocol Protocol (http/https)
58
+ # @option meta [String] :host Hostname of the test service
59
+ # @option meta [String] :basePath Base path of the test service
60
+ #
61
+ # @note Several fields have sensible defaults (e.g. +dctype+, +supportedby+, +applicationarea+).
62
+ # The +end_url+ and +identifier+ are automatically constructed from +protocol+, +host+,
63
+ # +basePath+, and +testid+.
64
+ #
65
+ def initialize(meta:)
66
+ indics = [meta[:indicators]] unless meta[:indicators].is_a? Array
67
+ @indicators = indics
68
+ @testid = meta[:testid]
69
+ @testname = meta[:testname]
70
+ @metric = meta[:metric]
71
+ @description = meta[:description] || "No description provided"
72
+ @keywords = meta[:keywords] || []
73
+ @keywords = [@keywords] unless @keywords.is_a? Array
74
+ @creator = meta[:creator]
75
+ @end_desc = meta[:end_desc]
76
+ @end_url = meta[:end_url]
77
+ @dctype = meta[:dctype] || "http://edamontology.org/operation_2428"
78
+ @supportedby = meta[:supportedby] || ["https://tools.ostrails.eu/champion"]
79
+ @applicationarea = meta[:applicationarea] || ["http://www.fairsharing.org/ontology/subject/SRAO_0000401"]
80
+ @isapplicablefor = meta[:isapplicablefor] || ["https://schema.org/Dataset"]
81
+ @landingpage = meta[:landingPage] || @end_url
82
+ @license = meta[:license] || "No License"
83
+ @themes = meta[:themes] || []
84
+ @themes = [@themes] unless @themes.is_a? Array
85
+ @testversion = meta[:testversion] || "unversioned"
86
+ @organizations = meta[:organizations] || []
87
+ @individuals = meta[:individuals] || []
88
+ @protocol = meta[:protocol]
89
+ @host = meta[:host]
90
+ @basePath = meta[:basePath]
91
+ cleanhost = @host.gsub("/", "")
92
+ cleanpath = @basePath.gsub("/", "") # TODO: this needs to check only leading and trailing! NOt internal...
93
+ endpointpath = "assess/test"
94
+ @end_url = "#{protocol}://#{cleanhost}/#{cleanpath}/#{endpointpath}/#{testid}"
95
+ @end_desc = "#{protocol}://#{cleanhost}/#{cleanpath}/#{testid}/api"
96
+ @identifier = "#{protocol}://#{cleanhost}/#{cleanpath}/#{testid}"
97
+
98
+ unless @testid && @testname && @description && @creator && @end_desc && @end_url && @protocol && @host && @basePath
99
+ warn "this record is invalid - it is missing one of testid testname description creator end_desc end_url protocol host basePath"
100
+ end
101
+ end
102
+
103
+ ##
104
+ # Returns an RDF::Graph containing the DCAT metadata for this test.
105
+ #
106
+ # The graph describes the test as both a +dcat:DataService+ and an +ftr:Test+.
107
+ # It includes:
108
+ #
109
+ # * Core DCAT properties (identifier, title, description, keywords, landing page, etc.)
110
+ # * FAIR-specific extensions via the FTR vocabulary
111
+ # * Contact points (individuals and organizations) using vCard
112
+ # * Link to the metric it implements (SIO)
113
+ # * Supported-by relationships, application areas, and applicability statements
114
+ #
115
+ # @return [RDF::Graph] RDF graph with the complete DCAT record
116
+ #
117
+ def get_dcat
118
+ schema = RDF::Vocab::SCHEMA
119
+ dcterms = RDF::Vocab::DC
120
+ xsd = RDF::Vocab::XSD
121
+ dcat = RDF::Vocab::DCAT
122
+ sio = RDF::Vocabulary.new("http://semanticscience.org/resource/")
123
+ ftr = RDF::Vocabulary.new("https://w3id.org/ftr#")
124
+ dqv = RDF::Vocabulary.new("http://www.w3.org/ns/dqv#")
125
+ vcard = RDF::Vocabulary.new("http://www.w3.org/2006/vcard/ns#")
126
+ dpv = RDF::Vocabulary.new("https://w3id.org/dpv#")
127
+
128
+ g = RDF::Graph.new
129
+ # me = "#{identifier}/about" # at the hackathon we decided that the test id would return the metadata
130
+ # so now there is no need for /about
131
+ me = "#{identifier}"
132
+
133
+ triplify(me, RDF.type, dcat.DataService, g)
134
+ triplify(me, RDF.type, ftr.Test, g)
135
+
136
+ # triplify tests and rejects anything that is empty or nil --> SAFE
137
+ # Test Unique Identifier dcterms:identifier Literal
138
+ triplify(me, dcterms.identifier, identifier.to_s, g, datatype: xsd.string)
139
+
140
+ # Title/Name of the test dcterms:title Literal
141
+ triplify(me, dcterms.title, testname, g)
142
+
143
+ # Description dcterms:description Literal
144
+ # descriptions.each do |d|
145
+ # triplify(me, dcterms.description, d, g)
146
+ # end
147
+ triplify(me, dcterms.description, description, g)
148
+
149
+ # Keywords dcat:keyword Literal
150
+ keywords.each do |kw|
151
+ triplify(me, dcat.keyword, kw, g)
152
+ end
153
+
154
+ # Test creator dcterms:creator dcat:Agent (URI)
155
+ triplify(me, dcterms.creator, creator, g)
156
+
157
+ # Dimension ftr:indicator
158
+ indicators.each do |ind|
159
+ triplify(me, dqv.inDimension, ind, g)
160
+ end
161
+
162
+ # API description dcat:endpointDescription rdfs:Resource
163
+ triplify(me, dcat.endpointDescription, end_desc, g)
164
+
165
+ # API URL dcat:endpointURL rdfs:Resource
166
+ triplify(me, dcat.endpointURL, end_url, g)
167
+
168
+ # API URL dcat:landingPage rdfs:Resource
169
+ triplify(me, dcat.landingPage, landingpage, g)
170
+
171
+ # Source of the test codemeta:hasSourceCode/schema:codeRepository/ doap:repository schema:SoftwareSourceCode/URL
172
+ # TODO
173
+ # FAIRChampion::Output.triplify(me, dcat.endpointDescription, end_desc, g)
174
+
175
+ # Functional Descriptor/Operation dcterms:type xsd:anyURI
176
+ triplify(me, dcterms.type, dctype, g)
177
+
178
+ # License dcterms:license xsd:anyURI
179
+ triplify(me, dcterms.license, license, g)
180
+
181
+ # Semantic Annotation dcat:theme xsd:anyURI
182
+ themes.each do |theme|
183
+ triplify(me, dcat.theme, theme, g)
184
+ end
185
+
186
+ # Version dcat:version rdfs:Literal
187
+ triplify(me, RDF::Vocab::DCAT.to_s + "version", testversion, g)
188
+
189
+ # # Version notes adms:versionNotes rdfs:Literal
190
+ # FAIRChampion::Output.triplify(me, dcat.version, version, g)
191
+
192
+ triplify(me, sio["SIO_000233"], metric, g) # is implementation of
193
+ triplify(metric, RDF.type, dqv.Metric, g) # is implementation of
194
+
195
+ # Responsible dcat:contactPoint dcat:Kind (includes Individual/Organization)
196
+ individuals.each do |i|
197
+ # i = {name: "Mark WAilkkinson", "email": "asmlkfj;askjf@a;lksdjfas"}
198
+ guid = SecureRandom.uuid
199
+ cp = "urn:fairchampion:testmetadata:individual#{guid}"
200
+ triplify(me, dcat.contactPoint, cp, g)
201
+ triplify(cp, RDF.type, vcard.Individual, g)
202
+ triplify(cp, vcard.fn, i["name"], g) if i["name"]
203
+ next unless i["email"]
204
+
205
+ email = i["email"].to_s
206
+ email = "mailto:#{email}" unless email =~ /mailto:/
207
+ triplify(cp, vcard.hasEmail, RDF::URI.new(email), g)
208
+ end
209
+
210
+ organizations.each do |o|
211
+ # i = {name: "CBGP", "url": "https://dbdsf.orhf"}
212
+ guid = SecureRandom.uuid
213
+ cp = "urn:fairchampion:testmetadata:org:#{guid}"
214
+ triplify(me, dcat.contactPoint, cp, g)
215
+ triplify(cp, RDF.type, vcard.Organization, g)
216
+ triplify(cp, vcard["organization-name"], o["name"], g)
217
+ triplify(cp, vcard.url, RDF::URI.new(o["url"].to_s), g)
218
+ end
219
+
220
+ supportedby.each do |tool|
221
+ triplify(me, ftr.supportedBy, tool, g)
222
+ triplify(tool, RDF.type, schema.SoftwareApplication, g)
223
+ end
224
+
225
+ applicationarea.each do |domain|
226
+ triplify(me, ftr.applicationArea, domain, g)
227
+ end
228
+ isapplicablefor.each do |digitalo|
229
+ triplify(me, dpv.isApplicableFor, digitalo, g)
230
+ end
231
+
232
+ g
233
+ end
234
+ end
235
+ end
data/lib/fdp_index.rb ADDED
@@ -0,0 +1,168 @@
1
+ require "sparql/client"
2
+ require "sparql"
3
+ require "json/ld"
4
+ require "json/ld/preloaded"
5
+ require "rdf/trig"
6
+ require "rdf/raptor"
7
+ require "fileutils" # For directory creation
8
+ require "digest" # For hashing URLs to filenames
9
+
10
+ module FtrRuby
11
+ class FDPIndex
12
+ # Cache directory and expiry time (in seconds, e.g., 24 hours)
13
+ CACHE_DIR = File.join(Dir.pwd, "cache", "rdf_repositories")
14
+ CACHE_EXPIRY = 240 * 60 * 60 # 24 hours in seconds
15
+
16
+ def self.retrieve_tests_from_index(indexendpoint: "https://tools.ostrails.eu/repositories/fdpindex-fdp")
17
+ sparql = SPARQL::Client.new(indexendpoint)
18
+
19
+ fdpindexquery = <<EOQUERY
20
+ PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
21
+ PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
22
+ PREFIX dqv: <http://www.w3.org/ns/dqv#>
23
+ PREFIX dct: <http://purl.org/dc/terms/>
24
+ PREFIX dcat: <http://www.w3.org/ns/dcat#>
25
+ PREFIX sio: <http://semanticscience.org/resource/>
26
+ PREFIX dpv: <http://www.w3.org/ns/dpv#>
27
+ PREFIX ftr: <https://w3id.org/ftr#>
28
+ SELECT distinct ?sub ?identifier ?title ?description ?endpoint ?openapi ?dimension ?objects ?domain ?benchmark_or_metric WHERE {
29
+ ?sub a <https://w3id.org/ftr#Test> ;
30
+ dct:title ?title ;
31
+ dct:description ?description ;
32
+ dct:identifier ?identifier .
33
+ OPTIONAL {?sub dpv:isApplicableFor ?objects }
34
+ OPTIONAL {?sub ftr:applicationArea ?domain }
35
+ OPTIONAL {?sub sio:SIO_000233 ?benchmark_or_metric } # implementation of#{" "}
36
+ OPTIONAL {?sub dcat:endpointDescription ?openapi }
37
+ OPTIONAL {?sub dcat:endpointURL ?endpoint }
38
+ OPTIONAL {?sub dqv:inDimension ?dimension }
39
+ }#{" "}
40
+ EOQUERY
41
+
42
+ alltests = []
43
+
44
+ begin
45
+ # Execute the query
46
+ results = sparql.query(fdpindexquery)
47
+
48
+ # Process the results
49
+ results.each_solution do |solution|
50
+ test_object = {
51
+ subj: solution[:sub]&.to_s,
52
+ identifier: solution[:identifier]&.to_s,
53
+ title: solution[:title]&.to_s,
54
+ description: solution[:description]&.to_s,
55
+ endpoint: solution[:endpoint]&.to_s,
56
+ openapi: solution[:openapi]&.to_s,
57
+ dimension: solution[:dimension]&.to_s,
58
+ objects: solution[:objects]&.to_s,
59
+ domain: solution[:domain]&.to_s,
60
+ benchmark_or_metric: solution[:benchmark_or_metric]&.to_s
61
+ }
62
+ alltests << test_object
63
+ end
64
+ rescue StandardError => e
65
+ puts "Error executing SPARQL query: #{e.message}"
66
+ end
67
+
68
+ alltests
69
+ end
70
+
71
+ def self.get_metrics_labels_for_tests(tests:)
72
+ labels = {}
73
+ cache = {} # In-memory cache for this request
74
+
75
+ # Ensure cache directory exists
76
+ FileUtils.mkdir_p(CACHE_DIR)
77
+
78
+ tests.each do |test|
79
+ metric = test[:benchmark_or_metric] # Assume required
80
+ warn "Processing metric: #{metric}"
81
+
82
+ # Generate a safe filename for the metric URL
83
+ cache_key = Digest::SHA256.hexdigest(metric)
84
+ cache_file = File.join(CACHE_DIR, "#{cache_key}.bin")
85
+
86
+ # Check in-memory cache first
87
+ if cache[metric]
88
+ repository = cache[metric]
89
+ else
90
+ # Try to load from disk cache
91
+ repository = load_from_cache(cache_file)
92
+ if repository
93
+ warn "Loaded #{metric} from cache"
94
+ else
95
+ # Cache miss: fetch from URL
96
+ warn "Fetching RDF for #{metric}"
97
+ repository = RDF::Repository.new
98
+ headers = { "Accept" => "application/ld+json" }
99
+ begin
100
+ RDF::Reader.open(metric, headers: headers) do |reader|
101
+ repository << reader
102
+ end
103
+ # Save to disk cache with timestamp
104
+ save_to_cache(cache_file, repository)
105
+ warn "Cached #{metric} to disk"
106
+ rescue StandardError => e
107
+ warn "Error fetching RDF for #{metric}: #{e.message}"
108
+ labels[metric] = "Unable to resolve #{metric} to RDF metadata"
109
+ next
110
+ end
111
+ end
112
+ cache[metric] = repository # Store in memory for this request
113
+ end
114
+
115
+ # SPARQL query to get label
116
+ fdpindexquery = <<-METRICLABEL
117
+ PREFIX dct: <http://purl.org/dc/terms/>
118
+ PREFIX schema: <http://schema.org/>
119
+ SELECT DISTINCT ?label WHERE {
120
+ { ?sub dct:title ?label }
121
+ UNION
122
+ { ?sub schema:name ?label }
123
+ }
124
+ METRICLABEL
125
+
126
+ # Parse and execute the SPARQL query
127
+ fdpindexquery = SPARQL.parse(fdpindexquery)
128
+ results = fdpindexquery.execute(repository)
129
+
130
+ # Assign the label (first result or fallback)
131
+ labels[metric] = if results&.first&.[](:label)&.to_s&.length&.positive?
132
+ results.first[:label].to_s
133
+ else
134
+ "Unnamed Metric"
135
+ end
136
+ end
137
+
138
+ labels
139
+ end
140
+
141
+ # Load RDF::Repository from disk cache if not expired
142
+ def self.load_from_cache(cache_file)
143
+ return nil unless File.exist?(cache_file)
144
+
145
+ # Read timestamp and serialized data
146
+ File.open(cache_file, "rb") do |file|
147
+ timestamp = Marshal.load(file)
148
+ if Time.now - timestamp < CACHE_EXPIRY
149
+ return Marshal.load(file) # Return cached RDF::Repository
150
+ end
151
+ end
152
+ nil # Cache expired or invalid
153
+ rescue StandardError => e
154
+ warn "Error loading cache from #{cache_file}: #{e.message}"
155
+ nil
156
+ end
157
+
158
+ # Save RDF::Repository to disk cache with timestamp
159
+ def self.save_to_cache(cache_file, repository)
160
+ File.open(cache_file, "wb") do |file|
161
+ Marshal.dump(Time.now, file) # Store timestamp
162
+ Marshal.dump(repository, file) # Store repository
163
+ end
164
+ rescue StandardError => e
165
+ warn "Error saving cache to #{cache_file}: #{e.message}"
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FtrRuby
4
+ VERSION = "0.1.0"
5
+ end
data/lib/ftr_ruby.rb ADDED
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ftr_ruby/version"
4
+ require "rest-client"
5
+ require "json"
6
+ require "sparql"
7
+ require "sparql/client"
8
+ require "linkeddata"
9
+ require "safe_yaml"
10
+ require "rdf/nquads"
11
+ require "cgi"
12
+ require "securerandom"
13
+ require "rdf/vocab"
14
+ require "triple_easy" # provides "triplify" top-level function
15
+
16
+ # lib/ftr_ruby.rb
17
+
18
+ require "dcat_metadata"
19
+ require "output"
20
+
21
+ module FtrRuby
22
+ class Error < StandardError; end
23
+ # Your code goes here...
24
+ end
data/lib/openapi.rb ADDED
@@ -0,0 +1,77 @@
1
+ class OpenAPI
2
+ attr_accessor :title, :metric, :description, :indicator, :testid,
3
+ :organization, :org_url, :version, :creator,
4
+ :responsible_developer, :email, :developer_ORCiD, :protocol,
5
+ :host, :basePath, :path, :response_description, :schemas, :endpointpath
6
+
7
+ def initialize(meta:)
8
+ indics = [meta[:indicators]] unless meta[:indicators].is_a? Array
9
+ @testid = meta[:testid]
10
+ @title = meta[:testname]
11
+ @version = meta[:testversion]
12
+ @metric = meta[:metric]
13
+ @description = meta[:description]
14
+ @indicator = indics.first
15
+ @organization = meta[:organization]
16
+ @org_url = meta[:org_url]
17
+ @responsible_developer = meta[:responsible_developer]
18
+ @email = meta[:email]
19
+ @creator = meta[:creator]
20
+ @host = meta[:host]
21
+ @host = @host.gsub(%r{/$}, "") # remove trailing slash if present
22
+ @protocol = meta[:protocol].gsub(%r{[:/]}, "")
23
+ @basePath = meta[:basePath].gsub(%r{[:/]}, "")
24
+ @basePath = "/#{basePath}" unless basePath[0] == "/" # must start with a slash
25
+ @path = meta[:path]
26
+ @response_description = meta[:response_description]
27
+ @schemas = meta[:schemas]
28
+ @endpointpath = "assess/test"
29
+ @end_url = "#{protocol}://#{host}#{basePath}/#{endpointpath}/#{testid}" # basepath starts with /
30
+ end
31
+
32
+ def get_api
33
+ <<~"EOF_EOF"
34
+
35
+ openapi: 3.0.0
36
+ info:
37
+ version: "#{version}"
38
+ title: "#{title}"
39
+ x-tests_metric: "#{metric}"
40
+ description: >-
41
+ "#{description}"
42
+ x-applies_to_principle: "#{indicator}"
43
+ contact:
44
+ x-organization: "#{organization}"
45
+ url: "#{org_url}"
46
+ name: "#{responsible_developer}"
47
+ x-role: responsible developer
48
+ email: "#{email}"
49
+ x-id: "#{creator}"
50
+ paths:
51
+ "/#{testid}":
52
+ post:
53
+ requestBody:
54
+ content:
55
+ application/json:
56
+ schema:
57
+ $ref: "#/components/schemas/schemas"
58
+ required: true
59
+ responses:
60
+ "200":
61
+ description: >-
62
+ #{response_description}
63
+ servers:
64
+ - url: "#{protocol}://#{host}/#{endpointpath}"
65
+ components:
66
+ schemas:
67
+ schemas:
68
+ required:
69
+ - resource_identifier
70
+ properties:
71
+ - resource_identifier:
72
+ type: string
73
+ description: the GUID being tested
74
+
75
+ EOF_EOF
76
+ end
77
+ end
data/lib/output.rb ADDED
@@ -0,0 +1,181 @@
1
+ module FtrRuby
2
+ ##
3
+ # Represents the output/result of executing a FAIR test against a specific resource.
4
+ #
5
+ # This class generates a provenance-rich RDF graph describing a single test execution,
6
+ # including the result score, log messages, summary, and optional guidance for improvement.
7
+ #
8
+ # The output follows the FAIR Test Registry (FTR) vocabulary and uses PROV for tracing
9
+ # the execution activity and the tested entity.
10
+ #
11
+ # @example
12
+ # output = FtrRuby::Output.new(
13
+ # testedGUID: "https://doi.org/10.5281/zenodo.1234567",
14
+ # meta: test_meta_hash
15
+ # )
16
+ # output.score = "pass"
17
+ # output.comments << "Resource has a resolvable persistent identifier."
18
+ # jsonld = output.createEvaluationResponse
19
+ #
20
+ class Output
21
+ include TripleEasy # get the :"triplify" function
22
+ # triplify(s, p, o, repo, datatype: nil, context: nil, language: 'en')
23
+ include RDF
24
+
25
+ attr_accessor :score, :testedGUID, :testid, :uniqueid, :name, :description, :license, :dt, :metric, :softwareid,
26
+ :version, :summary, :completeness, :comments, :guidance, :creator, :protocol, :host, :basePath, :api
27
+
28
+ OPUTPUT_VERSION = "1.1.1"
29
+
30
+ ##
31
+ # Creates a new test output instance.
32
+ #
33
+ # @param testedGUID [String] The identifier (usually URI) of the resource being tested
34
+ # @param meta [Hash] Metadata about the test (same structure used by DCAT_Record)
35
+ #
36
+ def initialize(testedGUID:, meta:)
37
+ @score = "indeterminate"
38
+ @testedGUID = testedGUID
39
+ @uniqueid = "urn:fairtestoutput:" + SecureRandom.uuid
40
+ @name = meta[:testname]
41
+ @description = meta[:description]
42
+ @license = meta[:license] || "https://creativecommons.org/licenses/by/4.0/"
43
+ @dt = Time.now.iso8601
44
+ @metric = meta[:metric]
45
+ @version = meta[:testversion]
46
+ @summary = meta[:summary] || "Summary:"
47
+ @completeness = "100"
48
+ @comments = []
49
+ @guidance = meta.fetch(:guidance, [])
50
+ @creator = meta[:creator]
51
+ @protocol = meta[:protocol].gsub(%r{[:/]}, "")
52
+ @host = meta[:host].gsub(%r{[:/]}, "")
53
+ @basePath = meta[:basePath].gsub(%r{[:/]}, "")
54
+ @softwareid = "#{@protocol}://#{@host}/#{@basePath}/#{meta[:testid]}"
55
+ @api = "#{@softwareid}/api"
56
+ end
57
+
58
+ ##
59
+ # Generates the full evaluation response as JSON-LD.
60
+ #
61
+ # Builds an RDF graph containing:
62
+ # * A `ftr:TestExecutionActivity`
63
+ # * A `ftr:TestResult` with score, summary, log, and guidance
64
+ # * Links to the tested entity and the test software
65
+ # * Provenance information
66
+ #
67
+ # @return [String] JSON-LD representation of the test result graph
68
+ #
69
+ def createEvaluationResponse
70
+ g = RDF::Graph.new
71
+ schema = RDF::Vocab::SCHEMA
72
+ xsd = RDF::Vocab::XSD
73
+ dct = RDF::Vocab::DC
74
+ prov = RDF::Vocab::PROV
75
+ dcat = RDF::Vocab::DCAT
76
+ dqv = RDF::Vocabulary.new("https://www.w3.org/TR/vocab-dqv/")
77
+ ftr = RDF::Vocabulary.new("https://w3id.org/ftr#")
78
+ sio = RDF::Vocabulary.new("http://semanticscience.org/resource/")
79
+ cwmo = RDF::Vocabulary.new("http://purl.org/cwmo/#")
80
+
81
+ add_newline_to_comments
82
+
83
+ if summary =~ /^Summary$/
84
+ summary = "Summary of test results: #{comments[-1]}"
85
+ summary ||= "Summary of test results: #{comments[-2]}"
86
+ end
87
+
88
+ executionid = "urn:ostrails:testexecutionactivity:" + SecureRandom.uuid
89
+
90
+ # tid = 'urn:ostrails:fairtestentity:' + SecureRandom.uuid
91
+ # The entity is no longer an anonymous node, it is the GUID Of the tested input
92
+
93
+ triplify(executionid, RDF.type, ftr.TestExecutionActivity, g)
94
+ triplify(executionid, prov.wasAssociatedWith, softwareid, g)
95
+ triplify(uniqueid, prov.wasGeneratedBy, executionid, g)
96
+
97
+ triplify(uniqueid, RDF.type, ftr.TestResult, g)
98
+ triplify(uniqueid, dct.identifier, uniqueid.to_s, g, datatype: xsd.string)
99
+ triplify(uniqueid, dct.title, "#{name} OUTPUT", g)
100
+ triplify(uniqueid, dct.description, "OUTPUT OF #{description}", g)
101
+ triplify(uniqueid, dct.license, license, g)
102
+ triplify(uniqueid, prov.value, score, g)
103
+ triplify(uniqueid, ftr.summary, summary, g)
104
+ triplify(uniqueid, RDF::Vocab::PROV.generatedAtTime, dt, g)
105
+ triplify(uniqueid, ftr.log, comments.join, g)
106
+ triplify(uniqueid, ftr.completion, completeness, g)
107
+
108
+ triplify(uniqueid, ftr.outputFromTest, softwareid, g)
109
+ triplify(softwareid, RDF.type, ftr.Test, g)
110
+ triplify(softwareid, RDF.type, schema.SoftwareApplication, g)
111
+ triplify(softwareid, RDF.type, dcat.DataService, g)
112
+ triplify(softwareid, dct.identifier, softwareid.to_s, g, datatype: xsd.string)
113
+ triplify(softwareid, dct.title, "#{name}", g)
114
+ triplify(softwareid, dct.description, description, g)
115
+ triplify(softwareid, dcat.endpointDescription, api, g) # returns yaml
116
+ triplify(softwareid, dcat.endpointURL, softwareid, g) # POST to execute
117
+ triplify(softwareid, "http://www.w3.org/ns/dcat#version", "#{version} OutputVersion:#{OPUTPUT_VERSION}", g) # dcat namespace in library has no version - dcat 2 not 3
118
+ triplify(softwareid, dct.license, "https://github.com/wilkinsonlab/FAIR-Core-Tests/blob/main/LICENSE", g)
119
+ triplify(softwareid, sio["SIO_000233"], metric, g) # implementation of
120
+
121
+ # deprecated after release 1.0
122
+ # triplify(uniqueid, prov.wasDerivedFrom, tid, g)
123
+ # triplify(executionid, prov.used, tid, g)
124
+ # triplify(tid, RDF.type, prov.Entity, g)
125
+ # triplify(tid, schema.identifier, testedGUID, g, xsd.string)
126
+ # triplify(tid, schema.url, testedGUID, g) if testedGUID =~ %r{^https?://}
127
+ testedguidnode = "urn:ostrails:testedidentifiernode:" + SecureRandom.uuid
128
+
129
+ begin
130
+ triplify(uniqueid, ftr.assessmentTarget, testedguidnode, g)
131
+ triplify(executionid, prov.used, testedguidnode, g)
132
+ triplify(testedguidnode, RDF.type, prov.Entity, g)
133
+ triplify(testedguidnode, dct.identifier, testedGUID, g, datatype: xsd.string)
134
+ rescue StandardError
135
+ triplify(uniqueid, ftr.assessmentTarget, "not a URI", g)
136
+ triplify(executionid, prov.used, "not a URI", g)
137
+ score = "fail"
138
+ end
139
+
140
+ unless score == "pass"
141
+ guidance.each do |advice, label|
142
+ adviceid = "urn:ostrails:testexecutionactivity:advice:" + SecureRandom.uuid
143
+ triplify(uniqueid, ftr.suggestion, adviceid, g)
144
+ triplify(adviceid, RDF.type, ftr.GuidanceContext, g)
145
+ triplify(adviceid, RDFS.label, label, g)
146
+ triplify(adviceid, dct.description, label, g)
147
+ triplify(adviceid, sio["SIO_000339"], RDF::URI.new(advice), g)
148
+ end
149
+ end
150
+
151
+ # g.dump(:jsonld)
152
+ w = RDF::Writer.for(:jsonld)
153
+ w.dump(g, nil, prefixes: {
154
+ xsd: RDF::Vocab::XSD,
155
+ prov: RDF::Vocab::PROV,
156
+ dct: RDF::Vocab::DC,
157
+ dcat: RDF::Vocab::DCAT,
158
+ ftr: ftr,
159
+ sio: sio,
160
+ schema: schema
161
+ })
162
+ end
163
+
164
+ class << self
165
+ attr_reader :comments
166
+ end
167
+
168
+ def self.clear_comments
169
+ @comments = []
170
+ end
171
+
172
+ def add_newline_to_comments
173
+ cleancomments = []
174
+ @comments.each do |c|
175
+ c += "\n" unless c =~ /\n$/
176
+ cleancomments << c
177
+ end
178
+ @comments = cleancomments
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,23 @@
1
+ require "rest-client"
2
+ module FtrRuby
3
+ class Tests
4
+ def self.register_test(test_uri:)
5
+ warn "registering new test"
6
+ # curl -v -L -H "content-type: application/json"
7
+ # -d '{"clientUrl": "https://my.domain.org/path/to/DCAT/testdcat.ttl"}'
8
+ # https://tools.ostrails.eu/fdp-index-proxy/proxy
9
+ begin
10
+ response = RestClient::Request.execute({
11
+ method: :post,
12
+ url: "https://tools.ostrails.eu/fdp-index-proxy/proxy",
13
+ headers: { "Accept" => "application/json",
14
+ "Content-Type" => "application/json" },
15
+ payload: { "clientUrl": test_uri }.to_json
16
+ }).body
17
+ rescue StandardError => e
18
+ warn "response is #{response.inspect} error #{e.inspect}"
19
+ end
20
+ response
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,47 @@
1
+ module FtrRuby
2
+ class TestInfra
3
+ # there is a need to map between a test and its registered Metric in FS. This will return the label for the test
4
+ # in principle, we cojuld return a more complex object, but all I need now is the label
5
+ def self.get_tests_metrics(tests:)
6
+ base_url = ENV["TEST_BASE_URL"] || "http://localhost:8282" # Default to local server
7
+ test_path = ENV["TEST_PATH"] || "community-tests" # Default to local server
8
+ labels = {}
9
+ landingpages = {}
10
+ tests.each do |testid|
11
+ warn "getting dcat for #{testid} #{base_url}/#{test_path}/#{testid}"
12
+ dcat = RestClient::Request.execute({
13
+ method: :get,
14
+ url: "#{base_url}/#{test_path}/#{testid}",
15
+ headers: { "Accept" => "application/json" }
16
+ }).body
17
+ parseddcat = JSON.parse(dcat)
18
+ # this next line should probably be done with SPARQL
19
+ # # TODO TODO TODO
20
+ jpath = JsonPath.new('[0]["http://semanticscience.org/resource/SIO_000233"][0]["@id"]') # is implementation of
21
+ metricurl = jpath.on(parseddcat).first
22
+
23
+ begin
24
+ g = RDF::Graph.load(metricurl, format: :turtle)
25
+ rescue StandardError => e
26
+ warn "DCAT Metric loading failed #{e.inspect}"
27
+ g = RDF::Graph.new
28
+ end
29
+
30
+ title = g.query([nil, RDF::Vocab::DC.title, nil])&.first&.object&.to_s
31
+ lp = g.query([nil, RDF::Vocab::DCAT.landingPage, nil])&.first&.object&.to_s
32
+
33
+ labels[testid] = if title != ""
34
+ title
35
+ else
36
+ "Metric label not available"
37
+ end
38
+ landingpages[testid] = if lp != ""
39
+ lp
40
+ else
41
+ ""
42
+ end
43
+ end
44
+ [labels, landingpages]
45
+ end
46
+ end
47
+ end
data/sig/ftr_ruby.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module FtrRuby
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,59 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ftr_ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - markwilkinson
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Libraries supporting the FAIR Testing Resources Vocabulary - Tests and
13
+ test outputs.
14
+ email:
15
+ - mark.wilkinson@upm.es
16
+ executables: []
17
+ extensions: []
18
+ extra_rdoc_files: []
19
+ files:
20
+ - CHANGELOG.md
21
+ - LICENSE.txt
22
+ - README.md
23
+ - Rakefile
24
+ - lib/dcat_metadata.rb
25
+ - lib/fdp_index.rb
26
+ - lib/ftr_ruby.rb
27
+ - lib/ftr_ruby/version.rb
28
+ - lib/openapi.rb
29
+ - lib/output.rb
30
+ - lib/registertest.rb
31
+ - lib/test_infrastructure.rb
32
+ - sig/ftr_ruby.rbs
33
+ homepage: https://github.com/markwilkinson/FTR-Ruby/tree/master
34
+ licenses:
35
+ - MIT
36
+ metadata:
37
+ allowed_push_host: https://rubygems.org
38
+ homepage_uri: https://github.com/markwilkinson/FTR-Ruby/tree/master
39
+ source_code_uri: https://github.com/markwilkinson/FTR-Ruby/tree/master
40
+ documentation_uri: https://rubydoc.info/gems/ftr_ruby
41
+ bug_tracker_uri: https://github.com/markwilkinson/FTR-Ruby/issues
42
+ rdoc_options: []
43
+ require_paths:
44
+ - lib
45
+ required_ruby_version: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: 3.2.0
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ requirements: []
56
+ rubygems_version: 4.0.9
57
+ specification_version: 4
58
+ summary: Libraries supporting the FTR Vocabulary.
59
+ test_files: []