Moved changeset consistency checks to library code.
[rails.git] / app / controllers / changeset_controller.rb
1 # The ChangesetController is the RESTful interface to Changeset objects
2
3 class ChangesetController < ApplicationController
4   require 'xml/libxml'
5
6   before_filter :authorize, :only => [:create, :update, :delete, :upload, :include, :close]
7   before_filter :check_write_availability, :only => [:create, :update, :delete, :upload, :include]
8   before_filter :check_read_availability, :except => [:create, :update, :delete, :upload, :download, :query]
9   after_filter :compress_output
10
11   # Help methods for checking boundary sanity and area size
12   include MapBoundary
13
14   # Helper methods for checking consistency
15   include ConsistencyValidations
16
17   # Create a changeset from XML.
18   def create
19     if request.put?
20       cs = Changeset.from_xml(request.raw_post, true)
21
22       if cs
23         cs.user_id = @user.id
24         cs.save_with_tags!
25         render :text => cs.id.to_s, :content_type => "text/plain"
26       else
27         render :nothing => true, :status => :bad_request
28       end
29     else
30       render :nothing => true, :status => :method_not_allowed
31     end
32   end
33
34   def read
35     begin
36       changeset = Changeset.find(params[:id])
37       render :text => changeset.to_xml.to_s, :content_type => "text/xml"
38     rescue ActiveRecord::RecordNotFound
39       render :nothing => true, :status => :not_found
40     end
41   end
42   
43   ##
44   # marks a changeset as closed. this may be called multiple times
45   # on the same changeset, so is idempotent.
46   def close 
47     unless request.put?
48       render :nothing => true, :status => :method_not_allowed
49       return
50     end
51     
52     changeset = Changeset.find(params[:id])    
53     check_changeset_consistency(changeset, @user)
54
55     # to close the changeset, we'll just set its closed_at time to
56     # now. this might not be enough if there are concurrency issues, 
57     # but we'll have to wait and see.
58     changeset.set_closed_time_now
59
60     changeset.save!
61     render :nothing => true
62   rescue ActiveRecord::RecordNotFound
63     render :nothing => true, :status => :not_found
64   rescue OSM::APIError => ex
65     render ex.render_opts
66   end
67
68   ##
69   # insert a (set of) points into a changeset bounding box. this can only
70   # increase the size of the bounding box. this is a hint that clients can
71   # set either before uploading a large number of changes, or changes that
72   # the client (but not the server) knows will affect areas further away.
73   def expand_bbox
74     # only allow POST requests, because although this method is
75     # idempotent, there is no "document" to PUT really...
76     if request.post?
77       cs = Changeset.find(params[:id])
78       check_changeset_consistency(cs, @user)
79
80       # keep an array of lons and lats
81       lon = Array.new
82       lat = Array.new
83
84       # the request is in pseudo-osm format... this is kind-of an
85       # abuse, maybe should change to some other format?
86       doc = XML::Parser.string(request.raw_post).parse
87       doc.find("//osm/node").each do |n|
88         lon << n['lon'].to_f * GeoRecord::SCALE
89         lat << n['lat'].to_f * GeoRecord::SCALE
90       end
91
92       # add the existing bounding box to the lon-lat array
93       lon << cs.min_lon unless cs.min_lon.nil?
94       lat << cs.min_lat unless cs.min_lat.nil?
95       lon << cs.max_lon unless cs.max_lon.nil?
96       lat << cs.max_lat unless cs.max_lat.nil?
97
98       # collapse the arrays to minimum and maximum
99       cs.min_lon, cs.min_lat, cs.max_lon, cs.max_lat = 
100         lon.min, lat.min, lon.max, lat.max
101
102       # save the larger bounding box and return the changeset, which
103       # will include the bigger bounding box.
104       cs.save!
105       render :text => cs.to_xml.to_s, :content_type => "text/xml"
106
107     else
108       render :nothing => true, :status => :method_not_allowed
109     end
110
111   rescue ActiveRecord::RecordNotFound
112     render :nothing => true, :status => :not_found
113   rescue OSM::APIError => ex
114     render ex.render_opts
115   end
116
117   ##
118   # Upload a diff in a single transaction.
119   #
120   # This means that each change within the diff must succeed, i.e: that
121   # each version number mentioned is still current. Otherwise the entire
122   # transaction *must* be rolled back.
123   #
124   # Furthermore, each element in the diff can only reference the current
125   # changeset.
126   #
127   # Returns: a diffResult document, as described in 
128   # http://wiki.openstreetmap.org/index.php/OSM_Protocol_Version_0.6
129   def upload
130     # only allow POST requests, as the upload method is most definitely
131     # not idempotent, as several uploads with placeholder IDs will have
132     # different side-effects.
133     # see http://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html#sec9.1.2
134     unless request.post?
135       render :nothing => true, :status => :method_not_allowed
136       return
137     end
138
139     changeset = Changeset.find(params[:id])
140     check_changeset_consistency(changeset, @user)
141     
142     diff_reader = DiffReader.new(request.raw_post, changeset)
143     Changeset.transaction do
144       result = diff_reader.commit
145       render :text => result.to_s, :content_type => "text/xml"
146     end
147     
148   rescue ActiveRecord::RecordNotFound
149     render :nothing => true, :status => :not_found
150   rescue OSM::APIError => ex
151     render ex.render_opts
152   end
153
154   ##
155   # download the changeset as an osmChange document.
156   #
157   # to make it easier to revert diffs it would be better if the osmChange
158   # format were reversible, i.e: contained both old and new versions of 
159   # modified elements. but it doesn't at the moment...
160   #
161   # this method cannot order the database changes fully (i.e: timestamp and
162   # version number may be too coarse) so the resulting diff may not apply
163   # to a different database. however since changesets are not atomic this 
164   # behaviour cannot be guaranteed anyway and is the result of a design
165   # choice.
166   def download
167     changeset = Changeset.find(params[:id])
168     
169     # get all the elements in the changeset and stick them in a big array.
170     elements = [changeset.old_nodes, 
171                 changeset.old_ways, 
172                 changeset.old_relations].flatten
173     
174     # sort the elements by timestamp and version number, as this is the 
175     # almost sensible ordering available. this would be much nicer if 
176     # global (SVN-style) versioning were used - then that would be 
177     # unambiguous.
178     elements.sort! do |a, b| 
179       if (a.timestamp == b.timestamp)
180         a.version <=> b.version
181       else
182         a.timestamp <=> b.timestamp 
183       end
184     end
185     
186     # create an osmChange document for the output
187     result = OSM::API.new.get_xml_doc
188     result.root.name = "osmChange"
189
190     # generate an output element for each operation. note: we avoid looking
191     # at the history because it is simpler - but it would be more correct to 
192     # check these assertions.
193     elements.each do |elt|
194       result.root <<
195         if (elt.version == 1) 
196           # first version, so it must be newly-created.
197           created = XML::Node.new "create"
198           created << elt.to_xml_node
199         else
200           # get the previous version from the element history
201           prev_elt = elt.class.find(:first, :conditions => 
202                                     ['id = ? and version = ?',
203                                      elt.id, elt.version])
204           unless elt.visible
205             # if the element isn't visible then it must have been deleted, so
206             # output the *previous* XML
207             deleted = XML::Node.new "delete"
208             deleted << prev_elt.to_xml_node
209           else
210             # must be a modify, for which we don't need the previous version
211             # yet...
212             modified = XML::Node.new "modify"
213             modified << elt.to_xml_node
214           end
215         end
216     end
217
218     render :text => result.to_s, :content_type => "text/xml"
219             
220   rescue ActiveRecord::RecordNotFound
221     render :nothing => true, :status => :not_found
222   rescue OSM::APIError => ex
223     render ex.render_opts
224   end
225
226   ##
227   # query changesets by bounding box, time, user or open/closed status.
228   def query
229     # create the conditions that the user asked for. some or all of
230     # these may be nil.
231     conditions = conditions_bbox(params['bbox'])
232     conditions = cond_merge conditions, conditions_user(params['user'])
233     conditions = cond_merge conditions, conditions_time(params['time'])
234     conditions = cond_merge conditions, conditions_open(params['open'])
235
236     # create the results document
237     results = OSM::API.new.get_xml_doc
238
239     # add all matching changesets to the XML results document
240     Changeset.find(:all, 
241                    :conditions => conditions, 
242                    :limit => 100,
243                    :order => 'created_at desc').each do |cs|
244       results.root << cs.to_xml_node
245     end
246
247     render :text => results.to_s, :content_type => "text/xml"
248
249   rescue ActiveRecord::RecordNotFound
250     render :nothing => true, :status => :not_found
251   rescue OSM::APIError => ex
252     render ex.render_opts
253   end
254   
255   ##
256   # updates a changeset's tags. none of the changeset's attributes are
257   # user-modifiable, so they will be ignored.
258   #
259   # changesets are not (yet?) versioned, so we don't have to deal with
260   # history tables here. changesets are locked to a single user, however.
261   #
262   # after succesful update, returns the XML of the changeset.
263   def update
264     # request *must* be a PUT.
265     unless request.put?
266       render :nothing => true, :status => :method_not_allowed
267       return
268     end
269     
270     changeset = Changeset.find(params[:id])
271     new_changeset = Changeset.from_xml(request.raw_post)
272
273     unless new_changeset.nil?
274       check_changeset_consistency(changeset, @user)
275       changeset.update_from(new_changeset, @user)
276       render :text => changeset.to_xml, :mime_type => "text/xml"
277     else
278       
279       render :nothing => true, :status => :bad_request
280     end
281       
282   rescue ActiveRecord::RecordNotFound
283     render :nothing => true, :status => :not_found
284   rescue OSM::APIError => ex
285     render ex.render_opts
286   end
287
288 private
289   #------------------------------------------------------------
290   # utility functions below.
291   #------------------------------------------------------------  
292
293   ##
294   # merge two conditions
295   def cond_merge(a, b)
296     if a and b
297       a_str = a.shift
298       b_str = b.shift
299       return [ a_str + " and " + b_str ] + a + b
300     elsif a 
301       return a
302     else b
303       return b
304     end
305   end
306
307   ##
308   # if a bounding box was specified then parse it and do some sanity 
309   # checks. this is mostly the same as the map call, but without the 
310   # area restriction.
311   def conditions_bbox(bbox)
312     unless bbox.nil?
313       raise OSM::APIBadUserInput.new("Bounding box should be min_lon,min_lat,max_lon,max_lat") unless bbox.count(',') == 3
314       bbox = sanitise_boundaries(bbox.split(/,/))
315       raise OSM::APIBadUserInput.new("Minimum longitude should be less than maximum.") unless bbox[0] <= bbox[2]
316       raise OSM::APIBadUserInput.new("Minimum latitude should be less than maximum.") unless bbox[1] <= bbox[3]
317       return ['min_lon < ? and max_lon > ? and min_lat < ? and max_lat > ?',
318               bbox[2] * GeoRecord::SCALE, bbox[0] * GeoRecord::SCALE, bbox[3]* GeoRecord::SCALE, bbox[1] * GeoRecord::SCALE]
319     else
320       return nil
321     end
322   end
323
324   ##
325   # restrict changesets to those by a particular user
326   def conditions_user(user)
327     unless user.nil?
328       # user input checking, we don't have any UIDs < 1
329       raise OSM::APIBadUserInput.new("invalid user ID") if user.to_i < 1
330
331       u = User.find(user.to_i)
332       # should be able to get changesets of public users only, or 
333       # our own changesets regardless of public-ness.
334       unless u.data_public?
335         # get optional user auth stuff so that users can see their own
336         # changesets if they're non-public
337         setup_user_auth
338         
339         raise OSM::APINotFoundError if @user.nil? or @user.id != u.id
340       end
341       return ['user_id = ?', u.id]
342     else
343       return nil
344     end
345   end
346
347   ##
348   # restrict changes to those during a particular time period
349   def conditions_time(time) 
350     unless time.nil?
351       # if there is a range, i.e: comma separated, then the first is 
352       # low, second is high - same as with bounding boxes.
353       if time.count(',') == 1
354         # check that we actually have 2 elements in the array
355         times = time.split(/,/)
356         raise OSM::APIBadUserInput.new("bad time range") if times.size != 2 
357
358         from, to = times.collect { |t| DateTime.parse(t) }
359         return ['closed_at >= ? and created_at <= ?', from, to]
360       else
361         # if there is no comma, assume its a lower limit on time
362         return ['closed_at >= ?', DateTime.parse(time)]
363       end
364     else
365       return nil
366     end
367     # stupid DateTime seems to throw both of these for bad parsing, so
368     # we have to catch both and ensure the correct code path is taken.
369   rescue ArgumentError => ex
370     raise OSM::APIBadUserInput.new(ex.message.to_s)
371   rescue RuntimeError => ex
372     raise OSM::APIBadUserInput.new(ex.message.to_s)
373   end
374
375   ##
376   # restrict changes to those which are open
377   def conditions_open(open)
378     return open.nil? ? nil : ['closed_at >= ?', DateTime.now]
379   end
380
381 end