rdf-ldp 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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