diff --git a/app/assets/javascripts/angular/services/work-package-service.js b/app/assets/javascripts/angular/services/work-package-service.js index 4187650c7b..48203561c4 100644 --- a/app/assets/javascripts/angular/services/work-package-service.js +++ b/app/assets/javascripts/angular/services/work-package-service.js @@ -134,7 +134,7 @@ module.exports = function($http, PathHelper, WorkPackagesHelper, HALAPIResource, data: JSON.stringify(data), contentType: "application/json; charset=utf-8" }, force: true}; - return workPackage.links.update.fetch(options).then(function(workPackage) { + return workPackage.links.updateImmediately.fetch(options).then(function(workPackage) { return workPackage; }) }, diff --git a/lib/api/errors/error_base.rb b/lib/api/errors/error_base.rb index 88d7f65565..1b11b48efa 100644 --- a/lib/api/errors/error_base.rb +++ b/lib/api/errors/error_base.rb @@ -33,9 +33,11 @@ module API attr_reader :code, :message, :details, :errors def self.create(errors) - [:error_unauthorized, :error_conflict, :error_readonly].each do |key| + [:error_not_found, :error_unauthorized, :error_conflict, :error_readonly].each do |key| if errors.has_key?(key) case key + when :error_not_found + return ::API::Errors::NotFound.new(errors[key].join(' ')) when :error_unauthorized return ::API::Errors::Unauthorized when :error_conflict diff --git a/lib/api/v3/render/render_api.rb b/lib/api/v3/render/render_api.rb index 89c6f2cccc..2ad540d8b9 100644 --- a/lib/api/v3/render/render_api.rb +++ b/lib/api/v3/render/render_api.rb @@ -31,7 +31,6 @@ module API module V3 module Render class RenderAPI < Grape::API - include OpenProject::TextFormatting format :txt resources :render do @@ -43,43 +42,32 @@ module API end def context_object + try_context_object + rescue ::ActiveRecord::RecordNotFound + fail API::Errors::InvalidRenderContext.new('Context does not exist!') + end + + def try_context_object if params[:context] - context_object = nil - namespace, id = parse_context + context = parse_context - case namespace + case context[:ns] when 'work_packages' - context_object = WorkPackage.visible(current_user).find_by_id(id) - end - - unless context_object - fail API::Errors::InvalidRenderContext.new('Context does not exist!') + WorkPackage.visible(current_user).find(context[:id]) end end end def parse_context - contexts = API::V3::Root.routes.map do |route| - route_options = route.instance_variable_get(:@options) - match = route_options[:compiled].match(params[:context]) + context = ::API::V3::Utilities::ResourceLinkParser.parse(params[:context]) - if match - { - ns: /\/(?\w+)\//.match(route_options[:namespace])[:ns], - id: match[:id] - } - end - end - - contexts.compact!.uniq! { |c| c[:ns] } - - fail API::Errors::InvalidRenderContext.new('No context found.') if contexts.empty? - - unless SUPPORTED_CONTEXT_NAMESPACES.include? contexts[0][:ns] + if context.nil? + fail API::Errors::InvalidRenderContext.new('No context found.') + elsif !SUPPORTED_CONTEXT_NAMESPACES.include? context[:ns] fail API::Errors::InvalidRenderContext.new('Unsupported context found.') + else + context end - - [contexts[0][:ns], contexts[0][:id]] end def render(type) diff --git a/lib/api/v3/statuses/statuses_api.rb b/lib/api/v3/statuses/statuses_api.rb index 51c5405ca7..4a621fc994 100644 --- a/lib/api/v3/statuses/statuses_api.rb +++ b/lib/api/v3/statuses/statuses_api.rb @@ -39,6 +39,12 @@ module API get do StatusCollectionRepresenter.new(@statuses) end + + namespace ':id' do + get do + fail NotImplementedError + end + end end end end diff --git a/lib/api/v3/utilities/resource_link_parser.rb b/lib/api/v3/utilities/resource_link_parser.rb new file mode 100644 index 0000000000..62af69e1d5 --- /dev/null +++ b/lib/api/v3/utilities/resource_link_parser.rb @@ -0,0 +1,52 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2014 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +module API + module V3 + module Utilities + module ResourceLinkParser + def self.parse(resource_link) + API::V3::Root.routes.each do |route| + route_options = route.instance_variable_get(:@options) + match = route_options[:compiled].match(resource_link) + + if match + return { + ns: /\/(?\w+)\//.match(route_options[:namespace])[:ns], + id: match[:id] + } + end + end + + nil + end + end + end + end +end diff --git a/lib/api/v3/work_packages/form/form_api.rb b/lib/api/v3/work_packages/form/form_api.rb new file mode 100644 index 0000000000..d2b26edf6a --- /dev/null +++ b/lib/api/v3/work_packages/form/form_api.rb @@ -0,0 +1,58 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2014 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +module API + module V3 + module WorkPackages + module Form + class FormAPI < Grape::API + helpers do + def process_form_request + write_work_package_attributes + write_request_valid? + + error = ::API::Errors::ErrorBase.create(@representer.represented.errors) + + if error.is_a? ::API::Errors::Validation + status 200 + FormRepresenter.new(@representer.represented, current_user: current_user) + else + fail error + end + end + + end + + post '/form' do + process_form_request + end + end + end + end + end +end diff --git a/lib/api/v3/work_packages/form/form_representer.rb b/lib/api/v3/work_packages/form/form_representer.rb new file mode 100644 index 0000000000..27c93a2a34 --- /dev/null +++ b/lib/api/v3/work_packages/form/form_representer.rb @@ -0,0 +1,111 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2014 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'roar/decorator' +require 'roar/json/hal' + +module API + module V3 + module WorkPackages + module Form + class FormRepresenter < Roar::Decorator + include Roar::JSON::HAL + include Roar::Hypermedia + include API::Utilities::UrlHelper + include OpenProject::TextFormatting + + self.as_strategy = ::API::Utilities::CamelCasingStrategy.new + + def initialize(model, options = {}) + @current_user = options[:current_user] + + super(model) + end + + property :_type, exec_context: :decorator, writeable: false + + link :self do + { + href: "#{root_path}api/v3/work_packages/#{represented.id}/form", + } + end + + link :validate do + { + href: "#{root_path}api/v3/work_packages/#{represented.id}/form", + method: :post + } + end + + link :previewMarkup do + { + href: "#{root_path}api/v3/render/textile?"\ + "#{root_path}api/v3/work_packages/#{represented.id}", + method: :post + } + end + + link :commit do + { + href: "#{root_path}api/v3/work_packages/#{represented.id}", + method: :patch + } if @current_user.allowed_to?(:edit_work_packages, represented.project) && + represented.valid? + end + + property :payload, + embedded: true, + decorator: Form::WorkPackagePayloadRepresenter, + getter: -> (*) { self } + property :schema, + embedded: true, + exec_context: :decorator, + getter: -> (*) { + Form::WorkPackageSchemaRepresenter.new(represented, + current_user: @current_user) + } + property :validation_errors, embedded: true, exec_context: :decorator + + def _type + 'Form' + end + + def validation_errors + errors = represented.errors + + errors.keys.each_with_object({}) do |key, hash| + error = ::API::Errors::Validation.new(errors.full_message(key, errors[key])) + hash[key] = ::API::V3::Errors::ErrorRepresenter.new(error) + end + end + end + end + end + end +end diff --git a/lib/api/v3/work_packages/form/work_package_attribute_links_representer.rb b/lib/api/v3/work_packages/form/work_package_attribute_links_representer.rb new file mode 100644 index 0000000000..775720d58e --- /dev/null +++ b/lib/api/v3/work_packages/form/work_package_attribute_links_representer.rb @@ -0,0 +1,59 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2014 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'roar/decorator' +require 'roar/json/hal' + +module API + module V3 + module WorkPackages + module Form + class WorkPackageAttributeLinksRepresenter < Roar::Decorator + include Roar::JSON::HAL + include Roar::Hypermedia + include API::Utilities::UrlHelper + + self.as_strategy = ::API::Utilities::CamelCasingStrategy.new + + property :status, + exec_context: :decorator, + getter: -> (*) { + { href: "#{root_path}api/v3/statuses/#{represented.status.id}" } + }, + setter: -> (value, *) { + resource = ::API::V3::Utilities::ResourceLinkParser.parse value['href'] + + represented.status_id = resource[:id] if resource[:ns] == 'statuses' + }, + if: -> (*) { represented.status } + end + end + end + end +end diff --git a/lib/api/v3/work_packages/form/work_package_payload_representer.rb b/lib/api/v3/work_packages/form/work_package_payload_representer.rb new file mode 100644 index 0000000000..25cf03be7c --- /dev/null +++ b/lib/api/v3/work_packages/form/work_package_payload_representer.rb @@ -0,0 +1,104 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2014 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'roar/decorator' +require 'roar/json/hal' + +module API + module V3 + module WorkPackages + module Form + class WorkPackagePayloadRepresenter < Roar::Decorator + include Roar::JSON::HAL + include Roar::Hypermedia + + self.as_strategy = ::API::Utilities::CamelCasingStrategy.new + + def initialize(represented, options={}) + if options[:enforce_lock_version_validation] + # enforces availibility validation of lock_version + represented.lock_version = nil + end + + super(represented) + end + + property :_type, exec_context: :decorator, writeable: false + + property :linked_resources, + as: :_links, + exec_context: :decorator, + getter: -> (*) { + work_package_attribute_links_representer represented + }, + setter: -> (value, *) { + representer = work_package_attribute_links_representer represented + representer.from_json(value.to_json) + } + + property :lock_version + property :subject, render_nil: true + property :raw_description, + getter: -> (*) { description }, + setter: -> (value, *) { self.description = value }, + render_nil: true + property :parent_id, writeable: true + + property :project_id, getter: -> (*) { project.id } + property :start_date, + getter: -> (*) { + start_date.to_datetime.utc.iso8601 unless start_date.nil? + }, + render_nil: true + property :due_date, + getter: -> (*) { + due_date.to_datetime.utc.iso8601 unless due_date.nil? + }, + render_nil: true + property :version_id, + getter: -> (*) { fixed_version.try(:id) }, + setter: -> (value, *) { self.fixed_version_id = value }, + render_nil: true + property :created_at, getter: -> (*) { created_at.utc.iso8601 }, render_nil: true + property :updated_at, getter: -> (*) { updated_at.utc.iso8601 }, render_nil: true + + def _type + 'WorkPackage' + end + + private + + def work_package_attribute_links_representer(represented) + ::API::V3::WorkPackages::Form::WorkPackageAttributeLinksRepresenter.new represented + end + end + end + end + end +end diff --git a/lib/api/v3/work_packages/form/work_package_schema_representer.rb b/lib/api/v3/work_packages/form/work_package_schema_representer.rb new file mode 100644 index 0000000000..3fc75a0562 --- /dev/null +++ b/lib/api/v3/work_packages/form/work_package_schema_representer.rb @@ -0,0 +1,94 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2014 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'roar/decorator' +require 'roar/json/hal' + +module API + module V3 + module WorkPackages + module Form + class WorkPackageSchemaRepresenter < Roar::Decorator + include Roar::JSON::HAL + include Roar::Hypermedia + include API::Utilities::UrlHelper + + self.as_strategy = ::API::Utilities::CamelCasingStrategy.new + + def initialize(model, options = {}) + @current_user = options[:current_user] + + super(model) + end + + property :_type, + getter: -> (*) { { type: 'MetaType', required: true, writable: false } }, + writeable: false + property :lock_version, + getter: -> (*) { { type: 'Integer', required: true, writable: false } }, + writeable: false + property :subject, + getter: -> (*) { { type: 'String' } }, + writeable: false + property :status, + exec_context: :decorator, + getter: -> (*) { represented.new_statuses_allowed_to(@current_user) } do + include Roar::JSON::HAL + + self.as_strategy = ::API::Utilities::CamelCasingStrategy.new + + property :links_to_allowed_statuses, + as: :_links, + getter: -> (*) { self } do + include API::Utilities::UrlHelper + + self.as_strategy = ::API::Utilities::CamelCasingStrategy.new + + property :allowed_values, exec_context: :decorator + + def allowed_values + represented.map do |status| + { href: "#{root_path}api/v3/statuses/#{status.id}", title: status.name } + end + end + end + + property :type, getter: -> (*) { 'Status' } + + collection :allowed_values, + embedded: true, + class: ::Status, + decorator: ::API::V3::Statuses::StatusRepresenter, + getter: -> (*) { self } + end + end + end + end + end +end diff --git a/lib/api/v3/work_packages/link_to_object_extractor.rb b/lib/api/v3/work_packages/link_to_object_extractor.rb new file mode 100644 index 0000000000..ab33e528c4 --- /dev/null +++ b/lib/api/v3/work_packages/link_to_object_extractor.rb @@ -0,0 +1,49 @@ +#-- encoding: UTF-8 +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2014 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +module API + module V3 + module WorkPackages + module LinkToObjectExtractor + def self.parse_links(links) + links.keys.each_with_object({}) do |attribute, h| + resource = ::API::V3::Utilities::ResourceLinkParser.parse links[attribute]['href'] + + if resource + case resource[:ns] + when 'statuses' + h[:status_id] = resource[:id] + end + end + end + end + end + end + end +end diff --git a/lib/api/v3/work_packages/work_package_contract.rb b/lib/api/v3/work_packages/work_package_contract.rb index 6189d8115d..0cccd39b5b 100644 --- a/lib/api/v3/work_packages/work_package_contract.rb +++ b/lib/api/v3/work_packages/work_package_contract.rb @@ -34,7 +34,13 @@ module API module V3 module WorkPackages class WorkPackageContract < Reform::Contract - WRITEABLE_ATTRIBUTES = ['lock_version', 'subject', 'parent_id', 'description'].freeze + WRITEABLE_ATTRIBUTES = [ + 'lock_version', + 'subject', + 'parent_id', + 'description', + 'status_id' + ].freeze def initialize(object, user) super(object) @@ -43,9 +49,10 @@ module API @can = WorkPackagePolicy.new(user) end + validate :user_allowed_to_access validate :user_allowed_to_edit validate :user_allowed_to_edit_parent - validate :lock_version_set + validate :lock_version_valid validate :readonly_attributes_unchanged extend Reform::Form::ActiveModel::ModelValidations @@ -53,6 +60,13 @@ module API private + def user_allowed_to_access + unless ::WorkPackage.visible(@user).exists?(model) + message = "Couldn't find WorkPackage with id=#{model.id}" + errors.add :error_not_found, message + end + end + def user_allowed_to_edit errors.add :error_unauthorized, '' unless @can.allowed?(model, :edit) end @@ -63,8 +77,8 @@ module API end end - def lock_version_set - errors.add :error_conflict, '' if model.lock_version.nil? + def lock_version_valid + errors.add :error_conflict, '' if model.lock_version.nil? || model.lock_version_changed? end def readonly_attributes_unchanged diff --git a/lib/api/v3/work_packages/work_package_representer.rb b/lib/api/v3/work_packages/work_package_representer.rb index b89845b35c..4270ff1324 100644 --- a/lib/api/v3/work_packages/work_package_representer.rb +++ b/lib/api/v3/work_packages/work_package_representer.rb @@ -58,6 +58,14 @@ module API end link :update do + { + href: "#{root_path}api/v3/work_packages/#{represented.id}/form", + method: :post, + title: "Update #{represented.subject}" + } if current_user_allowed_to(:edit_work_packages) + end + + link :updateImmediately do { href: "#{root_path}api/v3/work_packages/#{represented.id}", method: :patch, diff --git a/lib/api/v3/work_packages/work_packages_api.rb b/lib/api/v3/work_packages/work_packages_api.rb index c21d40e87f..6a221e6529 100644 --- a/lib/api/v3/work_packages/work_packages_api.rb +++ b/lib/api/v3/work_packages/work_packages_api.rb @@ -40,15 +40,20 @@ module API helpers do attr_reader :work_package - def decorate_work_package(work_package) - @representer = ::API::V3::WorkPackages::WorkPackageRepresenter.new(work_package, { current_user: current_user }, :activities, :users) + def write_work_package_attributes + if request_body + payload = ::API::V3::WorkPackages::Form::WorkPackagePayloadRepresenter + .new(@work_package, enforce_lock_version_validation: true) + + payload.from_json(request_body.to_json) + end end - def patch_request_body - env['api.request.input'] + def request_body + env['api.request.body'] end - def patch_request_valid? + def write_request_valid? contract = WorkPackageContract.new(@representer.represented, current_user) # Although the contract triggers the ActiveModel validations on @@ -68,7 +73,8 @@ module API before do @work_package = WorkPackage.find(params[:id]) - decorate_work_package(@work_package) + @representer = ::API::V3::WorkPackages::WorkPackageRepresenter + .new(work_package, { current_user: current_user }, :activities, :users) end get do @@ -77,9 +83,7 @@ module API end patch do - @representer.represented.lock_version = nil # enforces availibility validation of lock_version - - @representer.from_json(patch_request_body) + write_work_package_attributes send_notifications = !(params.has_key?(:notify) && params[:notify] == 'false') update_service = UpdateWorkPackageService.new(current_user, @@ -87,8 +91,8 @@ module API nil, send_notifications) - if patch_request_valid? && update_service.save - decorate_work_package(@work_package.reload) + if write_request_valid? && update_service.save + @representer.represented.reload @representer else fail ::API::Errors::ErrorBase.create(@representer.represented.errors) @@ -149,6 +153,7 @@ module API mount ::API::V3::WorkPackages::WatchersAPI mount ::API::V3::WorkPackages::StatusesAPI mount ::API::V3::Relations::RelationsAPI + mount ::API::V3::WorkPackages::Form::FormAPI end diff --git a/spec/lib/api/v3/work_packages/form/form_representer_spec.rb b/spec/lib/api/v3/work_packages/form/form_representer_spec.rb new file mode 100644 index 0000000000..b51263c648 --- /dev/null +++ b/spec/lib/api/v3/work_packages/form/form_representer_spec.rb @@ -0,0 +1,129 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2014 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe ::API::V3::WorkPackages::Form::FormRepresenter do + let(:work_package) { + FactoryGirl.build(:work_package, + id: 42, + created_at: DateTime.now, + updated_at: DateTime.now) + } + let(:current_user) { + FactoryGirl.build(:user, member_in_project: work_package.project) + } + let(:representer) { described_class.new(work_package, current_user: current_user) } + + context 'generation' do + subject(:generated) { representer.to_json } + + it { is_expected.to be_json_eql('Form'.to_json).at_path('_type') } + + describe '_links' do + it { is_expected.to have_json_path('_links') } + + it { is_expected.to have_json_path('_links/self/href') } + + describe 'validate' do + it { is_expected.to have_json_path('_links/validate/href') } + + it { is_expected.to be_json_eql(:post.to_json).at_path('_links/validate/method') } + end + + describe 'preview markup' do + it { is_expected.to have_json_path('_links/previewMarkup/href') } + + it { is_expected.to be_json_eql(:post.to_json).at_path('_links/previewMarkup/method') } + + it 'contains link to work package' do + body = parse_json(subject) + preview_markup_wp_link = body['_links']['previewMarkup']['href'].split('?')[1] + wp_self_link = body['_links']['commit']['href'] + + expect(preview_markup_wp_link).to eq(wp_self_link) + end + end + + describe 'commit' do + context 'valid work package' do + before { allow(work_package).to receive(:valid?).and_return(true) } + + it { is_expected.to have_json_path('_links/commit/href') } + + it { is_expected.to be_json_eql(:patch.to_json).at_path('_links/commit/method') } + end + + context 'invalid work package' do + before { allow(work_package).to receive(:valid?).and_return(false) } + + it { is_expected.not_to have_json_path('_links/commit/href') } + end + + context 'user with insufficient permissions' do + let(:role) { FactoryGirl.create(:role, permissions: []) } + let(:current_user) { + FactoryGirl.build(:user, + member_in_project: work_package.project, + member_through_role: role) + } + + before { allow(work_package).to receive(:valid?).and_return(true) } + + it { is_expected.not_to have_json_path('_links/commit/href') } + end + end + end + + describe 'validation errors' do + context 'w/o errors' do + it { is_expected.to be_json_eql({}.to_json).at_path('_embedded/validationErrors') } + end + + context 'with errors' do + let(:subject_error_message) { 'Subject can\'t be blank!' } + let(:status_error_message) { 'Status can\'t be blank!' } + let(:subject_error) { ::API::Errors::Validation.new(subject_error_message) } + let(:status_error) { ::API::Errors::Validation.new(status_error_message) } + let(:api_subject_error) { ::API::V3::Errors::ErrorRepresenter.new(subject_error) } + let(:api_status_error) { ::API::V3::Errors::ErrorRepresenter.new(status_error) } + let(:errors) { { subject: api_subject_error, status: api_status_error } } + + before do + allow(work_package.errors).to receive(:keys).and_return(errors.keys) + allow(work_package.errors).to receive(:full_message).with(:subject, []) + .and_return(subject_error_message) + allow(work_package.errors).to receive(:full_message).with(:status, []) + .and_return(status_error_message) + end + + it { is_expected.to be_json_eql(errors.to_json).at_path('_embedded/validationErrors') } + end + end + end +end diff --git a/spec/lib/api/v3/work_packages/form/work_package_payload_representer_spec.rb b/spec/lib/api/v3/work_packages/form/work_package_payload_representer_spec.rb new file mode 100644 index 0000000000..8830f56369 --- /dev/null +++ b/spec/lib/api/v3/work_packages/form/work_package_payload_representer_spec.rb @@ -0,0 +1,67 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2014 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe ::API::V3::WorkPackages::Form::WorkPackagePayloadRepresenter do + let(:work_package) { + FactoryGirl.build(:work_package, + created_at: DateTime.now, + updated_at: DateTime.now) + } + let(:representer) { described_class.new(work_package) } + + before { allow(work_package).to receive(:lock_version).and_return(1) } + + context 'generation' do + subject(:generated) { representer.to_json } + + it { is_expected.to include_json('WorkPackage'.to_json).at_path('_type') } + + describe 'work_package' do + it { is_expected.to have_json_path('subject') } + it { is_expected.to have_json_path('rawDescription') } + + describe 'lock version' do + it { is_expected.to have_json_path('lockVersion') } + + it { is_expected.to have_json_type(Integer).at_path('lockVersion') } + + it { is_expected.to be_json_eql(work_package.lock_version.to_json).at_path('lockVersion') } + end + end + + describe '_links' do + it { is_expected.to have_json_type(Object).at_path('_links') } + + it 'should link status' do + expect(subject).to have_json_path('_links/status/href') + end + end + end +end diff --git a/spec/lib/api/v3/work_packages/form/work_package_schema_representer_spec.rb b/spec/lib/api/v3/work_packages/form/work_package_schema_representer_spec.rb new file mode 100644 index 0000000000..79dc89bab4 --- /dev/null +++ b/spec/lib/api/v3/work_packages/form/work_package_schema_representer_spec.rb @@ -0,0 +1,126 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2014 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' + +describe ::API::V3::WorkPackages::Form::WorkPackageSchemaRepresenter do + let(:work_package) { FactoryGirl.build(:work_package) } + let(:current_user) { + FactoryGirl.build(:user, member_in_project: work_package.project) + } + let(:representer) { described_class.new(work_package, current_user: current_user) } + + context 'generation' do + subject(:generated) { representer.to_json } + + describe 'schema' do + shared_examples_for 'schema property' do |path, type, required, writable| + it { is_expected.to have_json_path(path) } + + it { is_expected.to be_json_eql(type.to_json).at_path("#{path}/type") } + + it 'has valid required value' do + required_path = "#{path}/required" + + if required.nil? + is_expected.not_to have_json_path(required_path) + else + is_expected.to be_json_eql(required.to_json).at_path(required_path) + end + end + + it 'has valid writable value' do + writable_path = "#{path}/writable" + + if writable.nil? + is_expected.not_to have_json_path(writable_path) + else + is_expected.to be_json_eql(writable.to_json).at_path(writable_path) + end + end + end + + describe '_type' do + it_behaves_like 'schema property', '_type', 'MetaType', true, false + end + + describe 'lock version' do + it_behaves_like 'schema property', 'lockVersion', 'Integer', true, false + end + + describe 'subject' do + it_behaves_like 'schema property', 'subject', 'String' + end + + describe 'status' do + shared_examples_for 'contains statuses' do + it { is_expected.to have_json_path('status') } + + it { is_expected.to have_json_path('status/_links') } + + it { is_expected.to have_json_path('status/_links/allowedValues') } + + it 'contains valid links to statuses' do + status_links = statuses.map do |status| + { href: "/api/v3/statuses/#{status.id}", title: status.name } + end + + is_expected.to be_json_eql(status_links.to_json).at_path('status/_links/allowedValues') + end + + it { is_expected.to be_json_eql('Status'.to_json).at_path('status/type') } + + it 'embeds statuses' do + embedded_statuses = statuses.map do |status| + { _type: 'Status', id: status.id, name: status.name } + end + + is_expected.to be_json_eql(embedded_statuses.to_json) + .at_path('status/_embedded/allowedValues') + end + end + + context 'w/o allowed statuses' do + before { allow(work_package).to receive(:new_statuses_allowed_to).and_return([]) } + + it_behaves_like 'contains statuses' do + let(:statuses) { [] } + end + end + + context 'with allowed statuses' do + let(:statuses) { FactoryGirl.build_list(:status, 3) } + + before { allow(work_package).to receive(:new_statuses_allowed_to).and_return(statuses) } + + it_behaves_like 'contains statuses' + end + end + end + end +end diff --git a/spec/lib/api/v3/work_packages/work_package_representer_spec.rb b/spec/lib/api/v3/work_packages/work_package_representer_spec.rb index aad4ef1817..600ba72eb3 100644 --- a/spec/lib/api/v3/work_packages/work_package_representer_spec.rb +++ b/spec/lib/api/v3/work_packages/work_package_representer_spec.rb @@ -45,7 +45,17 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do } let(:category) { FactoryGirl.build(:category) } let(:project) { work_package.project } - let(:permissions) { %i(view_work_packages view_work_package_watchers add_work_package_watchers delete_work_package_watchers manage_work_package_relations add_work_package_notes) } + let(:permissions) { + [ + :view_work_packages, + :view_work_package_watchers, + :edit_work_packages, + :add_work_package_watchers, + :delete_work_package_watchers, + :manage_work_package_relations, + :add_work_package_notes + ] + } let(:role) { FactoryGirl.create :role, permissions: permissions } before(:each) do @@ -124,6 +134,29 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do expect(subject).to have_json_path('_links/self/title') end + describe 'update links' do + describe 'update by form' do + it { expect(subject).to have_json_path('_links/update/href') } + it { + expect(subject).to be_json_eql("/api/v3/work_packages/#{work_package.id}/form".to_json) + .at_path('_links/update/href') + } + it { expect(subject).to be_json_eql('post'.to_json).at_path('_links/update/method') } + end + + describe 'immediate update' do + it { expect(subject).to have_json_path('_links/updateImmediately/href') } + it { + expect(subject).to be_json_eql("/api/v3/work_packages/#{work_package.id}".to_json) + .at_path('_links/updateImmediately/href') + } + it { + expect(subject).to be_json_eql('patch'.to_json) + .at_path('_links/updateImmediately/method') + } + end + end + describe 'version' do context 'no version set' do it { is_expected.to_not have_json_path('versionViewable') } @@ -304,7 +337,7 @@ describe ::API::V3::WorkPackages::WorkPackageRepresenter do end end - describe 'delete' do + describe 'log_time' do it_behaves_like 'action link' do let(:action) { 'log_time' } let(:permission) { :log_time } diff --git a/spec/requests/api/v3/work_package_resource_spec.rb b/spec/requests/api/v3/work_package_resource_spec.rb index 5cb53d981d..78c8a8653c 100644 --- a/spec/requests/api/v3/work_package_resource_spec.rb +++ b/spec/requests/api/v3/work_package_resource_spec.rb @@ -210,17 +210,37 @@ h4. things we like end context 'user without needed permissions' do - let(:current_user) { FactoryGirl.create :user } - let(:params) { valid_params } + context 'no permission to see the work package' do + let(:work_package) { FactoryGirl.create(:work_package, id: 42) } + let(:current_user) { FactoryGirl.create :user } + let(:params) { valid_params } - include_context 'patch request' + include_context 'patch request' - it_behaves_like 'unauthorized access' + it_behaves_like 'not found', 42, 'WorkPackage' + end + + context 'no permission to edit the work package' do + let(:role) { FactoryGirl.create(:role, permissions: [:view_work_packages]) } + let(:current_user) { + FactoryGirl.create(:user, + member_in_project: work_package.project, + member_through_role: role) + } + let(:params) { valid_params } + + include_context 'patch request' + + it_behaves_like 'unauthorized access' + end end context 'user with needed permissions' do shared_examples_for 'lock version updated' do - it { expect(subject.body).to be_json_eql(work_package.reload.lock_version).at_path('lockVersion') } + it { + expect(subject.body).to be_json_eql(work_package.reload.lock_version) + .at_path('lockVersion') + } end describe 'notification' do @@ -346,6 +366,44 @@ h4. things we like end end + context 'status' do + let(:target_status) { FactoryGirl.create(:status) } + let(:status_link) { "/api/v3/statuses/#{target_status.id}" } + let(:status_parameter) { { _links: { status: { href: status_link } } } } + let(:params) { valid_params.merge(status_parameter) } + + before { allow(User).to receive(:current).and_return current_user } + + context 'valid status' do + let!(:workflow) { + FactoryGirl.create(:workflow, + type_id: work_package.type.id, + old_status: work_package.status, + new_status: target_status, + role: current_user.memberships[0].roles[0]) + } + + include_context 'patch request' + + it { expect(response.status).to eq(200) } + + it 'should respond with updated work package status' do + expect(subject.body).to be_json_eql(target_status.name.to_json) + .at_path('status') + end + + it_behaves_like 'lock version updated' + end + + context 'invalid status' do + include_context 'patch request' + + it_behaves_like 'constraint violation', + 'Status no valid transition exists from old to new '\ + 'status for the current user roles.' + end + end + describe 'update with read-only attributes' do describe 'single read-only violation' do context 'start date' do @@ -469,7 +527,7 @@ h4. things we like it_behaves_like 'update conflict' end - context 'state object' do + context 'stale object' do let(:params) { valid_params.merge(subject: 'Updated subject') } before do diff --git a/spec/requests/api/v3/work_packages/form/work_package_form_resource_spec.rb b/spec/requests/api/v3/work_packages/form/work_package_form_resource_spec.rb new file mode 100644 index 0000000000..56d2c49227 --- /dev/null +++ b/spec/requests/api/v3/work_packages/form/work_package_form_resource_spec.rb @@ -0,0 +1,283 @@ +#-- copyright +# OpenProject is a project management system. +# Copyright (C) 2012-2014 the OpenProject Foundation (OPF) +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See doc/COPYRIGHT.rdoc for more details. +#++ + +require 'spec_helper' +require 'rack/test' + +describe 'API v3 Work package form resource', type: :request do + include Rack::Test::Methods + include Capybara::RSpecMatchers + + let(:project) { FactoryGirl.create(:project, is_public: false) } + let(:work_package) { FactoryGirl.create(:work_package, project: project) } + let(:authorized_user) { FactoryGirl.create(:user, member_in_project: project) } + let(:unauthorized_user) { FactoryGirl.create(:user) } + + describe '#post' do + let(:post_path) { "/api/v3/work_packages/#{work_package.id}/form" } + let(:valid_params) do + { + _type: 'WorkPackage', + lockVersion: work_package.lock_version + } + end + + subject(:response) { last_response } + + shared_context 'post request' do + before(:each) do + allow(User).to receive(:current).and_return current_user + post post_path, params.to_json, 'CONTENT_TYPE' => 'application/json' + end + end + + context 'user without needed permissions' do + let(:work_package) { FactoryGirl.create(:work_package, id: 42, project: project) } + let(:params) { {} } + + include_context 'post request' do + let(:current_user) { unauthorized_user } + end + + it_behaves_like 'not found', 42, 'WorkPackage' + end + + context 'user with needed permissions' do + let(:params) {} + let(:current_user) { authorized_user } + + context 'non-existing work package' do + let(:post_path) { '/api/v3/work_packages/eeek/form' } + + include_context 'post request' + + it_behaves_like 'not found', 'eeek', 'WorkPackage' + end + + context 'existing work package' do + shared_examples_for 'valid payload' do + it { expect(response.status).to eq(200) } + + it { expect(subject.body).to have_json_path('_embedded/payload') } + + it { expect(subject.body).to have_json_path('_embedded/payload/lockVersion') } + + it { expect(subject.body).to have_json_path('_embedded/payload/subject') } + + it { expect(subject.body).to have_json_path('_embedded/payload/rawDescription') } + end + + shared_examples_for 'valid payload with initial values' do + it { + expect(subject.body).to be_json_eql(work_package.lock_version.to_json) + .at_path('_embedded/payload/lockVersion') + } + + it { + expect(subject.body).to be_json_eql(work_package.subject.to_json) + .at_path('_embedded/payload/subject') + } + + it { + expect(subject.body).to be_json_eql(work_package.description.to_json) + .at_path('_embedded/payload/rawDescription') + } + end + + shared_examples_for 'having no errors' do + it { + expect(subject.body).to be_json_eql({}.to_json) + .at_path('_embedded/validationErrors') + } + end + + shared_examples_for 'having an error' do |property| + it { expect(subject.body).to have_json_path("_embedded/validationErrors/#{property}") } + + describe 'error body' do + let(:error_id) { 'urn:openproject-org:api:v3:errors:PropertyConstraintViolation' } + + let(:error_body) { + parse_json(subject.body)['_embedded']['validationErrors'][property] + } + + it { expect(error_body['errorIdentifier']).to eq(error_id) } + end + end + + describe 'body' do + context 'empty' do + include_context 'post request' + + it_behaves_like 'valid payload' + + it_behaves_like 'valid payload with initial values' + + it_behaves_like 'having no errors' + end + + context 'filled' do + let(:valid_params) do + { + _type: 'WorkPackage', + lockVersion: work_package.lock_version + } + end + + describe 'no change' do + let(:params) { valid_params } + + include_context 'post request' + + it_behaves_like 'valid payload' + + it_behaves_like 'valid payload with initial values' + + it_behaves_like 'having no errors' + end + + describe 'lock version' do + context 'missing lock version' do + let(:params) { valid_params.except(:lockVersion) } + + include_context 'post request' + + it_behaves_like 'update conflict' + end + + context 'stale object' do + let(:params) { valid_params.merge(subject: 'Updated subject') } + + before do + params + + work_package.subject = 'I am the first!' + work_package.save! + + expect(valid_params[:lockVersion]).not_to eq(work_package.lock_version) + end + + include_context 'post request' + + it { expect(response.status).to eq(409) } + + it_behaves_like 'update conflict' + end + end + + describe 'subject' do + include_context 'post request' + + context 'valid subject' do + let(:params) { valid_params.merge(subject: 'Updated subject') } + + it_behaves_like 'valid payload' + + it_behaves_like 'having no errors' + + it 'should respond with updated work package subject' do + expect(subject.body).to be_json_eql('Updated subject'.to_json) + .at_path('_embedded/payload/subject') + end + end + + context 'invalid subject' do + let(:params) { valid_params.merge(subject: nil) } + + it_behaves_like 'valid payload' + + it_behaves_like 'having an error', 'subject' + + it 'should respond with updated work package subject' do + expect(subject.body).to be_json_eql(nil.to_json) + .at_path('_embedded/payload/subject') + end + end + end + + describe 'description' do + let(:path) { '_embedded/payload/rawDescription' } + let(:description) { '*Some text* _describing_ *something*...' } + let(:params) { valid_params.merge(rawDescription: description) } + + include_context 'post request' + + it_behaves_like 'valid payload' + + it_behaves_like 'having no errors' + + it 'should respond with updated work package description' do + expect(subject.body).to be_json_eql(description.to_json).at_path(path) + end + end + + describe 'status' do + let(:path) { '_embedded/payload/_links/status/href' } + let(:target_status) { FactoryGirl.create(:status) } + let(:status_link) { "/api/v3/statuses/#{target_status.id}" } + let(:status_parameter) { { _links: { status: { href: status_link } } } } + let(:params) { valid_params.merge(status_parameter) } + + context 'valid status' do + let!(:workflow) { + FactoryGirl.create(:workflow, + type_id: work_package.type.id, + old_status: work_package.status, + new_status: target_status, + role: current_user.memberships[0].roles[0]) + } + + include_context 'post request' + + it_behaves_like 'valid payload' + + it_behaves_like 'having no errors' + + it 'should respond with updated work package status' do + expect(subject.body).to be_json_eql(status_link.to_json).at_path(path) + end + end + + context 'invalid status' do + include_context 'post request' + + it_behaves_like 'valid payload' + + it_behaves_like 'having an error', 'status_id' + + it 'should respond with updated work package status' do + expect(subject.body).to be_json_eql(status_link.to_json).at_path(path) + end + end + end + end + end + end + end + end +end