rdf-ldp 0.1.0 → 0.2.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.
@@ -0,0 +1,190 @@
1
+ require 'digest/md5'
2
+
3
+ module RDF::LDP
4
+ ##
5
+ # The base class for all directly usable LDP Resources that *are not*
6
+ # `NonRDFSources`. RDFSources are implemented as a resource with:
7
+ #
8
+ # - a `#subject_uri` identifying the RDFSource (see: {RDF::LDP::Resource}).
9
+ # - a `#graph` representing the "entire persistent state"
10
+ # - a `#metagraph` containing internal properties of the RDFSource
11
+ #
12
+ # Persistence schemes must be able to reconstruct both `#graph` and
13
+ # `#metagraph` accurately and separately (e.g. by saving them as distinct
14
+ # named graphs). Statements in `#metagraph` are considered canonical for the
15
+ # purposes of server-side operations; in the `RDF::LDP` core, this means they
16
+ # determine interaction model.
17
+ #
18
+ # Note that the contents of `#metagraph`'s are *not* the same as
19
+ # LDP-server-managed triples. `#metagraph` contains statements internal
20
+ # properties of the RDFSource which are necessary for the server's management
21
+ # purposes, but MAY be absent from the representation of its state in `#graph`.
22
+ # `#metagraph` is invisible to the client unless the implementation mirrors
23
+ # its contents in `#graph`.
24
+ #
25
+ # @see http://www.w3.org/TR/ldp/#dfn-linked-data-platform-rdf-source definition
26
+ # of ldp:RDFSource in the LDP specification
27
+ class RDFSource < Resource
28
+ attr_accessor :graph
29
+
30
+ class << self
31
+ ##
32
+ # @return [RDF::URI] uri with lexical representation
33
+ # 'http://www.w3.org/ns/ldp#RDFSource'
34
+ #
35
+ # @see http://www.w3.org/TR/ldp/#dfn-linked-data-platform-rdf-source
36
+ def to_uri
37
+ RDF::Vocab::LDP.RDFSource
38
+ end
39
+ end
40
+
41
+ def initialize(subject_uri, data = RDF::Repository.new)
42
+ @graph = RDF::Graph.new(subject_uri, data: data)
43
+ super
44
+ self
45
+ end
46
+
47
+ ##
48
+ # Creates the RDFSource, populating its graph from the input given
49
+ #
50
+ # @param [IO, File, #to_s] input input (usually from a Rack env's
51
+ # `rack.input` key) used to determine the Resource's initial state.
52
+ # @param [#to_s] content_type a MIME content_type used to read the graph.
53
+ #
54
+ # @raise [RDF::LDP::RequestError]
55
+ # @raise [RDF::LDP::UnsupportedMediaType] if no reader can be found for the
56
+ # graph
57
+ # @raise [RDF::LDP::BadRequest] if the identified reader can't parse the
58
+ # graph
59
+ # @raise [RDF::LDP::Conflict] if the RDFSource already exists
60
+ #
61
+ # @return [RDF::LDP::Resource] self
62
+ def create(input, content_type, &block)
63
+ super
64
+ statements = parse_graph(input, content_type)
65
+ yield statements if block_given?
66
+ graph << statements
67
+ self
68
+ end
69
+
70
+ ##
71
+ # Updates the resource. Replaces the contents of `graph` with the parsed
72
+ # input.
73
+ #
74
+ # @param [IO, File, #to_s] input input (usually from a Rack env's
75
+ # `rack.input` key) used to determine the Resource's new state.
76
+ # @param [#to_s] content_type a MIME content_type used to interpret the
77
+ # input.
78
+ #
79
+ # @return [RDF::LDP::Resource] self
80
+ def update(input, content_type, &block)
81
+ return create(input, content_type) unless exists?
82
+ statements = parse_graph(input, content_type)
83
+ yield statements if block_given?
84
+ graph.clear!
85
+ graph << statements
86
+ self
87
+ end
88
+
89
+ ##
90
+ # Clears the graph and marks as destroyed.
91
+ #
92
+ # @see RDF::LDP::Resource#destroy
93
+ def destroy
94
+ @graph.clear
95
+ super
96
+ end
97
+
98
+ ##
99
+ # Returns an Etag. This may be a strong or a weak ETag.
100
+ #
101
+ # @return [String] an HTTP Etag
102
+ #
103
+ # @note the current implementation is a naive one that combines a couple of
104
+ # blunt heurisitics.
105
+ #
106
+ # @todo add an efficient hash function for RDF Graphs to RDF.rb and use that
107
+ # here?
108
+ #
109
+ # @see http://ceur-ws.org/Vol-1259/proceedings.pdf#page=65 for a recent
110
+ # treatment of digests for RDF graphs
111
+ #
112
+ # @see http://www.w3.org/TR/ldp#h-ldpr-gen-etags LDP ETag clause for GET
113
+ # @see http://www.w3.org/TR/ldp#h-ldpr-put-precond LDP ETag clause for PUT
114
+ # @see http://www.w3.org/Protocols/rfc2616/rfc2616-sec13.html#sec13.3.3
115
+ # description of strong vs. weak validators
116
+ def etag
117
+ subs = graph.subjects.map { |s| s.node? ? nil : s.to_s }
118
+ .compact.sort.join()
119
+ "\"#{Digest::MD5.base64digest(subs)}#{graph.statements.count}\""
120
+ end
121
+
122
+ ##
123
+ # @param [String] tag a tag to compare to `#etag`
124
+ # @return [Boolean] whether the given tag matches `#etag`
125
+ def match?(tag)
126
+ # return false unless tag.split('==').last == graph.statements.count.to_s
127
+ tag == etag
128
+ end
129
+
130
+ ##
131
+ # @return [Boolean] whether this is an ldp:RDFSource
132
+ def rdf_source?
133
+ true
134
+ end
135
+
136
+ ##
137
+ # @return [RDF::URI] the subject URI for this resource
138
+ def to_uri
139
+ subject_uri
140
+ end
141
+
142
+ ##
143
+ # Returns the graph representing this resource's state, without the graph
144
+ # context.
145
+ def to_response
146
+ RDF::Graph.new << graph
147
+ end
148
+
149
+ private
150
+
151
+ ##
152
+ # Process & generate response for PUT requsets.
153
+ def put(status, headers, env)
154
+ raise PreconditionFailed.new('Etag invalid') if
155
+ env.has_key?('HTTP_IF_MATCH') && !match?(env['HTTP_IF_MATCH'])
156
+
157
+ if exists?
158
+ update(env['rack.input'], env['CONTENT_TYPE'])
159
+ headers = update_headers(headers)
160
+ [200, headers, self]
161
+ else
162
+ create(env['rack.input'], env['CONTENT_TYPE'])
163
+ [201, update_headers(headers), self]
164
+ end
165
+ end
166
+
167
+ ##
168
+ # Finds an {RDF::Reader} appropriate for the given content_type and attempts
169
+ # to parse the graph string.
170
+ #
171
+ # @param [IO, File, String] graph an input stream to parse
172
+ # @param [#to_s] content_type the content type for the reader
173
+ #
174
+ # @return [RDF::Enumerable] the statements in the resulting graph
175
+ #
176
+ # @raise [RDF::LDP::UnsupportedMediaType] if no appropriate reader is found
177
+ #
178
+ # @todo handle cases where no content type is given? Does RDF::Reader have
179
+ # tools to help us here?
180
+ def parse_graph(graph, content_type)
181
+ reader = RDF::Reader.for(content_type: content_type.to_s)
182
+ raise(RDF::LDP::UnsupportedMediaType, content_type) if reader.nil?
183
+ begin
184
+ RDF::Graph.new << reader.new(graph, base_uri: subject_uri)
185
+ rescue RDF::ReaderError => e
186
+ raise RDF::LDP::BadRequest, e.message
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,318 @@
1
+ require 'link_header'
2
+
3
+ module RDF::LDP
4
+ class Resource
5
+ attr_reader :subject_uri
6
+ attr_accessor :metagraph
7
+
8
+ class << self
9
+ ##
10
+ # @return [RDF::URI] uri with lexical representation
11
+ # 'http://www.w3.org/ns/ldp#Resource'
12
+ #
13
+ # @see http://www.w3.org/TR/ldp/#dfn-linked-data-platform-resource
14
+ def to_uri
15
+ RDF::Vocab::LDP.Resource
16
+ end
17
+
18
+ ##
19
+ # Creates an unique id (URI Slug) for a resource.
20
+ #
21
+ # @note the current implementation uses {SecureRandom#uuid}.
22
+ #
23
+ # @return [String] a unique ID
24
+ def gen_id
25
+ SecureRandom.uuid
26
+ end
27
+
28
+ ##
29
+ # Finds an existing resource and
30
+ #
31
+ # @param [RDF::URI] uri the URI for the resource to be found
32
+ # @param [RDF::Repository] data a repostiory instance in which to find
33
+ # the resource.
34
+ #
35
+ # @raise [RDF::LDP::NotFound] when the resource doesn't exist
36
+ #
37
+ # @return [RDF::LDP::Resource] a resource instance matching the given URI;
38
+ # usually of a subclass
39
+ # from the interaction models.
40
+ def find(uri, data)
41
+ graph = RDF::Graph.new(uri / '#meta', data: data)
42
+ raise NotFound if graph.empty?
43
+
44
+ rdf_class = graph.query([uri, RDF.type, :o]).first
45
+ klass = INTERACTION_MODELS[rdf_class.object] if rdf_class
46
+ klass ||= RDFSource
47
+
48
+ klass.new(uri, data)
49
+ end
50
+
51
+ ##
52
+ # Retrieves the correct interaction model from the Link headers.
53
+ #
54
+ # Headers are handled intelligently, e.g. if a client sends a request with
55
+ # Resource, RDFSource, and BasicContainer headers, the server gives a
56
+ # BasicContainer. An error is thrown if the headers contain conflicting
57
+ # types (i.e. NonRDFSource and another Resource class).
58
+ #
59
+ # @param [String] link_header a string containing Link headers from an
60
+ # HTTP request (Rack env)
61
+ #
62
+ # @return [Class] a subclass of {RDF::LDP::Resource} matching the
63
+ # requested interaction model;
64
+ def interaction_model(link_header)
65
+ models = LinkHeader.parse(link_header)
66
+ .links.select { |link| link['rel'].downcase == 'type' }
67
+ .map { |link| link.href }
68
+
69
+ return RDFSource if models.empty?
70
+ match = INTERACTION_MODELS.keys.reverse.find { |u| models.include? u }
71
+
72
+ if match == RDF::LDP::NonRDFSource.to_uri
73
+ raise NotAcceptable if
74
+ models.include?(RDF::LDP::RDFSource.to_uri) ||
75
+ models.include?(RDF::LDP::Container.to_uri) ||
76
+ models.include?(RDF::LDP::DirectContainer.to_uri) ||
77
+ models.include?(RDF::LDP::IndirectContainer.to_uri) ||
78
+ models.include?(RDF::URI('http://www.w3.org/ns/ldp#BasicContainer'))
79
+ end
80
+
81
+ INTERACTION_MODELS[match] || RDFSource
82
+ end
83
+ end
84
+
85
+ def initialize(subject_uri, data = RDF::Repository.new)
86
+ @subject_uri = RDF::URI(subject_uri)
87
+ @data = data
88
+ @metagraph = RDF::Graph.new(metagraph_name, data: data)
89
+ yield self if block_given?
90
+ end
91
+
92
+ ##
93
+ # @abstract creates the resource
94
+ #
95
+ # @param [IO, File, #to_s] input input (usually from a Rack env's
96
+ # `rack.input` key) used to determine the Resource's initial state.
97
+ # @param [#to_s] content_type a MIME content_type used to interpret the
98
+ # input. This MAY be used as a content type for the created Resource
99
+ # (especially for `LDP::NonRDFSource`s).
100
+ #
101
+ # @raise [RDF::LDP::RequestError] when creation fails. May raise various
102
+ # subclasses for the appropriate response codes.
103
+ #
104
+ # @return [RDF::LDP::Resource] self
105
+ def create(input, content_type)
106
+ raise Conflict if exists?
107
+ metagraph << RDF::Statement(subject_uri, RDF.type, self.class.to_uri)
108
+ self
109
+ end
110
+
111
+ ##
112
+ # @abstract update the resource
113
+ #
114
+ # @param [IO, File, #to_s] input input (usually from a Rack env's
115
+ # `rack.input` key) used to determine the Resource's new state.
116
+ # @param [#to_s] content_type a MIME content_type used to interpret the
117
+ # input.
118
+ #
119
+ # @raise [RDF::LDP::RequestError] when update fails. May raise various
120
+ # subclasses for the appropriate response codes.
121
+ #
122
+ # @return [RDF::LDP::Resource] self
123
+ def update(input, content_type)
124
+ raise NotImplementedError
125
+ end
126
+
127
+ ##
128
+ # Mark the resource as destroyed.
129
+ #
130
+ # This adds a statment to the metagraph expressing that the resource has
131
+ # been deleted
132
+ #
133
+ # @return [RDF::LDP::Resource] self
134
+ #
135
+ # @todo Use of owl:Nothing is probably problematic. Define an internal
136
+ # namespace and class represeting deletion status as a stateful property.
137
+ def destroy
138
+ containers.each { |con| con.remove(self) if con.container? }
139
+ @metagraph << RDF::Statement(subject_uri, RDF.type, RDF::OWL.Nothing)
140
+ self
141
+ end
142
+
143
+ ##
144
+ # @return [Boolean] true if the resource exists within the repository
145
+ def exists?
146
+ @data.has_context? metagraph.context
147
+ end
148
+
149
+ ##
150
+ # @return [Boolean] true if resource has been destroyed
151
+ def destroyed?
152
+ !(@metagraph.query([subject_uri, RDF.type, RDF::OWL.Nothing]).empty?)
153
+ end
154
+
155
+ ##
156
+ # @return [Array<Symbol>] a list of HTTP methods allowed by this resource.
157
+ def allowed_methods
158
+ [:GET, :POST, :PUT, :DELETE, :PATCH, :OPTIONS, :HEAD].select do |m|
159
+ respond_to?(m.downcase, true)
160
+ end
161
+ end
162
+
163
+ ##
164
+ # @return [Boolean] whether this is an ldp:Resource
165
+ def ldp_resource?
166
+ true
167
+ end
168
+
169
+ ##
170
+ # @return [Boolean] whether this is an ldp:Container
171
+ def container?
172
+ false
173
+ end
174
+
175
+ ##
176
+ # @return [Boolean] whether this is an ldp:NonRDFSource
177
+ def non_rdf_source?
178
+ false
179
+ end
180
+
181
+ ##
182
+ # @return [Boolean] whether this is an ldp:RDFSource
183
+ def rdf_source?
184
+ false
185
+ end
186
+
187
+ ##
188
+ # @return [Array<RDF::LDP::Resource>] the container for this resource
189
+ def containers
190
+ @data.query([:s, RDF::Vocab::LDP.contains, subject_uri]).map do |st|
191
+ RDF::LDP::Resource.find(st.subject, @data)
192
+ end
193
+ end
194
+
195
+ ##
196
+ # Runs the request and returns the object's desired HTTP response body,
197
+ # conforming to the Rack interfare.
198
+ #
199
+ # @see http://www.rubydoc.info/github/rack/rack/master/file/SPEC#The_Body
200
+ # for Rack body documentation
201
+ def to_response
202
+ []
203
+ end
204
+ alias_method :each, :to_response
205
+
206
+ ##
207
+ # Build the response for the HTTP `method` given.
208
+ #
209
+ # The method passed in is symbolized, downcased, and sent to `self` with the
210
+ # other three parameters.
211
+ #
212
+ # Request methods are expected to return an Array appropriate for a Rack
213
+ # response; to return this object (e.g. for a sucessful GET) the response
214
+ # may be `[status, headers, self]`.
215
+ #
216
+ # If the method given is unimplemented, we understand it to require an HTTP
217
+ # 405 response, and throw the appropriate error.
218
+ #
219
+ # @param [#to_sym] method the HTTP request method of the response; this
220
+ # message will be downcased and sent to the object.
221
+ # @param [Fixnum] status an HTTP response code; this status should be sent
222
+ # back to the caller or altered, as appropriate.
223
+ # @param [Hash<String, String>] headers a hash mapping HTTP headers
224
+ # built for the response to their contents; these headers should be sent
225
+ # back to the caller or altered, as appropriate.
226
+ # @param [] env the Rack env for the request
227
+ #
228
+ # @return [Array<Fixnum, Hash<String, String>, #each] a new Rack response
229
+ # array.
230
+ def request(method, status, headers, env)
231
+ raise Gone if destroyed?
232
+ begin
233
+ send(method.to_sym.downcase, status, headers, env)
234
+ rescue NotImplementedError => e
235
+ raise MethodNotAllowed, method
236
+ end
237
+ end
238
+
239
+ private
240
+
241
+ ##
242
+ # Generate response for GET requests. Returns existing status and headers,
243
+ # with `self` as the body.
244
+ def get(status, headers, env)
245
+ [status, update_headers(headers), self]
246
+ end
247
+
248
+ ##
249
+ # Generate response for HEAD requsets. Adds appropriate headers and returns
250
+ # an empty body.
251
+ def head(status, headers, env)
252
+ [status, update_headers(headers), []]
253
+ end
254
+
255
+ ##
256
+ # Generate response for OPTIONS requsets. Adds appropriate headers and
257
+ # returns an empty body.
258
+ def options(status, headers, env)
259
+ [status, update_headers(headers), []]
260
+ end
261
+
262
+ ##
263
+ # Process & generate response for DELETE requests.
264
+ def delete(status, headers, env)
265
+ [204, headers, destroy]
266
+ end
267
+
268
+ ##
269
+ # @return [RDF::URI] the name for this resource's metagraph
270
+ def metagraph_name
271
+ subject_uri / '#meta'
272
+ end
273
+
274
+ ##
275
+ # @param [Hash<String, String>] headers
276
+ # @return [Hash<String, String>] the updated headers
277
+ def update_headers(headers)
278
+ headers['Link'] =
279
+ ([headers['Link']] + link_headers).compact.join(",")
280
+
281
+ headers['Allow'] = allowed_methods.join(', ')
282
+ headers['Accept-Post'] = accept_post if respond_to?(:post, true)
283
+
284
+ headers['ETag'] ||= etag if respond_to?(:etag)
285
+ headers
286
+ end
287
+
288
+ ##
289
+ # @return [String] the Accept-Post headers
290
+ def accept_post
291
+ RDF::Reader.map { |klass| klass.format.content_type }.flatten.join(', ')
292
+ end
293
+
294
+ ##
295
+ # @return [Array<String>] an array of link headers to add to the
296
+ # existing ones
297
+ #
298
+ # @see http://www.w3.org/TR/ldp/#h-ldpr-gen-linktypehdr
299
+ # @see http://www.w3.org/TR/ldp/#h-ldprs-are-ldpr
300
+ # @see http://www.w3.org/TR/ldp/#h-ldpnr-type
301
+ # @see http://www.w3.org/TR/ldp/#h-ldpc-linktypehdr
302
+ def link_headers
303
+ return [] unless is_a? RDF::LDP::Resource
304
+ headers = [link_type_header(RDF::LDP::Resource.to_uri)]
305
+ headers << link_type_header(RDF::LDP::RDFSource.to_uri) if rdf_source?
306
+ headers << link_type_header(RDF::LDP::NonRDFSource.to_uri) if
307
+ non_rdf_source?
308
+ headers << link_type_header(container_class) if container?
309
+ headers
310
+ end
311
+
312
+ ##
313
+ # @return [String] a string to insert into a Link header
314
+ def link_type_header(uri)
315
+ "<#{uri}>;rel=\"type\""
316
+ end
317
+ end
318
+ end
@@ -1 +1,19 @@
1
- VERSION = "0.1.0"
1
+ module RDF::LDP
2
+ module VERSION
3
+ FILE = File.expand_path('../../../../VERSION', __FILE__)
4
+ MAJOR, MINOR, TINY, EXTRA = File.read(FILE).chomp.split('.')
5
+ STRING = [MAJOR, MINOR, TINY, EXTRA].compact.join('.').freeze
6
+
7
+ ##
8
+ # @return [String]
9
+ def self.to_s() STRING end
10
+
11
+ ##
12
+ # @return [String]
13
+ def self.to_str() STRING end
14
+
15
+ ##
16
+ # @return [Array(Integer, Integer, Integer)]
17
+ def self.to_a() [MAJOR, MINOR, TINY] end
18
+ end
19
+ end
data/lib/rdf/ldp.rb CHANGED
@@ -1,2 +1,87 @@
1
- require 'rdf/ldp/vocab'
2
- require 'rdf/ldp/helper'
1
+ require 'rdf'
2
+ require 'rdf/vocab'
3
+
4
+ require 'rdf/ldp/version'
5
+
6
+ require 'rdf/ldp/resource'
7
+ require 'rdf/ldp/rdf_source'
8
+ require 'rdf/ldp/non_rdf_source'
9
+ require 'rdf/ldp/container'
10
+ require 'rdf/ldp/direct_container'
11
+ require 'rdf/ldp/indirect_container'
12
+
13
+ module RDF
14
+ module LDP
15
+ ##
16
+ # Interaction models are in reverse order of preference for POST/PUT
17
+ # requests; e.g. if a client sends a request with Resource, RDFSource, and
18
+ # BasicContainer headers, the server gives a basic container.
19
+ INTERACTION_MODELS = {
20
+ RDF::Vocab::LDP.Resource => RDF::LDP::RDFSource,
21
+ RDF::LDP::RDFSource.to_uri => RDF::LDP::RDFSource,
22
+ RDF::LDP::Container.to_uri => RDF::LDP::Container,
23
+ RDF::URI('http://www.w3.org/ns/ldp#BasicContainer') => RDF::LDP::Container,
24
+ RDF::LDP::DirectContainer.to_uri => RDF::LDP::DirectContainer,
25
+ RDF::LDP::IndirectContainer.to_uri => RDF::LDP::IndirectContainer,
26
+ RDF::LDP::NonRDFSource.to_uri => RDF::LDP::NonRDFSource
27
+ }.freeze
28
+
29
+ CONTAINER_CLASSES = {
30
+ basic: RDF::Vocab::LDP.BasicContainer,
31
+ direct: RDF::LDP::DirectContainer.to_uri,
32
+ indirect: RDF::LDP::IndirectContainer.to_uri
33
+ }
34
+
35
+ CONSTRAINED_BY = RDF::Vocab::LDP.constrainedBy
36
+
37
+ ##
38
+ # A base class for HTTP request errors.
39
+ #
40
+ # This and its subclasses are caught and handled by Rack::LDP middleware.
41
+ class RequestError < RuntimeError
42
+ STATUS = 500
43
+
44
+ def status
45
+ self.class::STATUS
46
+ end
47
+
48
+ def headers
49
+ uri =
50
+ 'https://github.com/no-reply/rdf-ldp/blob/master/CONSTRAINED_BY.md'
51
+ { 'Link' => "<#{uri}>;rel=\"#{CONSTRAINED_BY}\"" }
52
+ end
53
+ end
54
+
55
+ class BadRequest < RequestError
56
+ STATUS = 400
57
+ end
58
+
59
+ class NotFound < RequestError
60
+ STATUS = 404
61
+ end
62
+
63
+ class MethodNotAllowed < RequestError
64
+ STATUS = 405
65
+ end
66
+
67
+ class NotAcceptable < RequestError
68
+ STATUS = 406
69
+ end
70
+
71
+ class Conflict < RequestError
72
+ STATUS = 409
73
+ end
74
+
75
+ class Gone < RequestError
76
+ STATUS = 410
77
+ end
78
+
79
+ class PreconditionFailed < RequestError
80
+ STATUS = 412
81
+ end
82
+
83
+ class UnsupportedMediaType < RequestError
84
+ STATUS = 415
85
+ end
86
+ end
87
+ end