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