git-svn-id: http://svn-commit.rubyonrails.org/rails/trunk@5962 5ecf4fe2-1ee6-0310-87b1-e25e094e27de
This commit is contained in:
parent
56c5535466
commit
932e7b003c
@ -1,5 +1,7 @@
|
||||
*SVN*
|
||||
|
||||
* Mega documentation patches. #7025, #7069 [rwdaigle]
|
||||
|
||||
* Base.exists?(id, options) and Base#exists? check whether the resource is found. #6970 [rwdaigle]
|
||||
|
||||
* Query string support. [untext, Jeremy Kemper]
|
||||
|
@ -1 +1,230 @@
|
||||
= Active Resource -- Object-oriented REST services
|
||||
|
||||
Active Resource (ARes) connects business objects and REST web services. It is a library
|
||||
intended to provide transparent proxying capabilities between a client and a RESTful
|
||||
service (for which Rails provides the {Simply RESTful routing}[http://dev.rubyonrails.org/browser/trunk/actionpack/lib/action_controller/resources.rb] implementation).
|
||||
|
||||
=== Configuration & Usage
|
||||
|
||||
Configuration is as simple as inheriting from ActiveResource::Base and providing a site
|
||||
class variable:
|
||||
|
||||
class Person < ActiveResource::Base
|
||||
self.site = "http://api.people.com:3000/"
|
||||
end
|
||||
|
||||
Person is now REST enable and can invoke REST services very similarly to how ActiveRecord invokes
|
||||
lifecycle methods that operate against a persistent store.
|
||||
|
||||
# Find a person with id = 1
|
||||
# This will invoke the following Http call:
|
||||
# GET http://api.people.com:3000/people/1.xml
|
||||
# and will load up the XML response into a new
|
||||
# Person object
|
||||
#
|
||||
ryan = Person.find(1)
|
||||
Person.exists?(1) #=> true
|
||||
|
||||
# To create a new person - instantiate the object and call 'save',
|
||||
# which will invoke this Http call:
|
||||
# POST http://api.people.com:3000/people.xml
|
||||
# (and will submit the XML format of the person object in the request)
|
||||
#
|
||||
ryan = Person.new(:first => 'Ryan', :last => 'Daigle')
|
||||
ryan.save #=> true
|
||||
ryan.id #=> 2
|
||||
Person.exists?(ryan.id) #=> true
|
||||
ryan.exists? #=> true
|
||||
|
||||
# Updating is done with 'save' as well
|
||||
# PUT http://api.people.com:3000/people/1.xml
|
||||
#
|
||||
ryan = Person.find(1)
|
||||
ryan.first = 'Rizzle'
|
||||
ryan.save #=> true
|
||||
|
||||
# And destruction
|
||||
# DELETE http://api.people.com:3000/people/1.xml
|
||||
#
|
||||
ryan = Person.find(1)
|
||||
ryan.destroy #=> true # Or Person.delete(ryan.id)
|
||||
|
||||
|
||||
=== Protocol
|
||||
|
||||
ARes is built on a standard XML format for requesting and submitting resources. It mirrors the
|
||||
RESTful routing built into ActionController, though it's useful to discuss what ARes expects
|
||||
outside the context of ActionController as it is not dependent on a Rails-based RESTful implementation.
|
||||
|
||||
==== Find
|
||||
|
||||
GET Http requests expect the XML form of whatever resource/resources is/are being requested. So,
|
||||
for a request for a single element - the XML of that item is expected in response:
|
||||
|
||||
# Expects a response of
|
||||
#
|
||||
# <person><id>1</id><attribute1>value1</attribute1><attribute2>..</attribute2></person>
|
||||
#
|
||||
# for GET http://api.people.com:3000/people/1.xml
|
||||
#
|
||||
ryan = Person.find(1)
|
||||
|
||||
The XML document that is received is used to build a new object of type Person, with each
|
||||
XML element becoming an attribute on the object.
|
||||
|
||||
ryan.is_a? Person #=> true
|
||||
ryan.attribute1 #=> 'value1'
|
||||
|
||||
Any complex element (one that contains other elements) becomes its own object:
|
||||
|
||||
# With this response:
|
||||
#
|
||||
# <person><id>1</id><attribute1>value1</attribute1><complex><attribute2>value2</attribute2></complex></person>
|
||||
#
|
||||
# for GET http://api.people.com:3000/people/1.xml
|
||||
#
|
||||
ryan = Person.find(1)
|
||||
ryan.complex #=> <Person::Complex::xxxxx>
|
||||
ryan.complex.attribute2 #=> 'value2'
|
||||
|
||||
Collections can also be requested in a similar fashion
|
||||
|
||||
# Expects a response of
|
||||
#
|
||||
# <people>
|
||||
# <person><id>1</id><first>Ryan</first></person>
|
||||
# <person><id>2</id><first>Jim</first></person>
|
||||
# </people>
|
||||
#
|
||||
# for GET http://api.people.com:3000/people.xml
|
||||
#
|
||||
people = Person.find(:all)
|
||||
people.first #=> <Person::xxx 'first' => 'Ryan' ...>
|
||||
people.last #=> <Person::xxx 'first' => 'Jim' ...>
|
||||
|
||||
==== Create
|
||||
|
||||
Creating a new resource submits the xml form of the resource as the body of the request and expects
|
||||
a 'Location' header in the response with the RESTful URL location of the newly created resource. The
|
||||
id of the newly created resource is parsed out of the Location response header and automatically set
|
||||
as the id of the ARes object.
|
||||
|
||||
# <person><first>Ryan</first></person>
|
||||
#
|
||||
# is submitted as the body on
|
||||
#
|
||||
# POST http://api.people.com:3000/people.xml
|
||||
#
|
||||
# when save is called on a new Person object. An empty response is
|
||||
# is expected with a 'Location' header value:
|
||||
#
|
||||
# Response (200): Location: http://api.people.com:3000/people/2
|
||||
#
|
||||
ryan = Person.new(:first => 'Ryan')
|
||||
ryan.new? #=> true
|
||||
ryan.save #=> true
|
||||
ryan.new? #=> false
|
||||
ryan.id #=> 2
|
||||
|
||||
==== Update
|
||||
|
||||
'save' is also used to update an existing resource - and follows the same protocol as creating a resource
|
||||
with the exception that no response headers are needed - just an empty response when the update on the
|
||||
server side was successful.
|
||||
|
||||
# <person><first>Ryan</first></person>
|
||||
#
|
||||
# is submitted as the body on
|
||||
#
|
||||
# PUT http://api.people.com:3000/people/1.xml
|
||||
#
|
||||
# when save is called on an existing Person object. An empty response is
|
||||
# is expected with code (204)
|
||||
#
|
||||
ryan = Person.find(1)
|
||||
ryan.first #=> 'Ryan'
|
||||
ryan.first = 'Rizzle'
|
||||
ryan.save #=> true
|
||||
|
||||
==== Delete
|
||||
|
||||
Destruction of a resource can be invoked as a class and instance method of the resource.
|
||||
|
||||
# A request is made to
|
||||
#
|
||||
# DELETE http://api.people.com:3000/people/1.xml
|
||||
#
|
||||
# for both of these forms. An empty response with
|
||||
# is expected with response code (200)
|
||||
#
|
||||
ryan = Person.find(1)
|
||||
ryan.destroy #=> true
|
||||
ryan.exists? #=> false
|
||||
Person.delete(2) #=> true
|
||||
Person.exists?(2) #=> false
|
||||
|
||||
|
||||
=== Errors & Validation
|
||||
|
||||
Error handling and validation is handled in much the same manner as you're used to seeing in
|
||||
ActiveRecord. Both the response code in the Http response and the body of the response are used to
|
||||
indicate that an error occurred.
|
||||
|
||||
==== Resource errors
|
||||
|
||||
When a get is requested for a resource that does not exist, the Http '404' (resource not found)
|
||||
response code will be returned from the server which will raise an ActiveResource::ResourceNotFound
|
||||
exception.
|
||||
|
||||
# GET http://api.people.com:3000/people/1.xml
|
||||
# #=> Response (404)
|
||||
#
|
||||
ryan = Person.find(1) #=> Raises ActiveResource::ResourceNotFound
|
||||
|
||||
==== Validation errors
|
||||
|
||||
Creating and updating resources can lead to validation errors - i.e. 'First name cannot be empty' etc...
|
||||
These types of errors are denoted in the response by a response code of 400 and the xml representation
|
||||
of the validation errors. The save operation will then fail (with a 'false' return value) and the
|
||||
validation errors can be accessed on the resource in question.
|
||||
|
||||
# When
|
||||
#
|
||||
# PUT http://api.people.com:3000/people/1.xml
|
||||
#
|
||||
# is requested with invalid values, the expected response is:
|
||||
#
|
||||
# Response (400):
|
||||
# <errors><error>First cannot be empty</error></errors>
|
||||
#
|
||||
ryan = Person.find(1)
|
||||
ryan.first #=> ''
|
||||
ryan.save #=> false
|
||||
ryan.errors.invalid?(:first) #=> true
|
||||
ryan.errors.full_messages #=> ['First cannot be empty']
|
||||
|
||||
|
||||
==== Response errors
|
||||
|
||||
If the underlying Http request for an ARes operation results in an error response code, an
|
||||
exception will be raised. The following Http response codes will result in these exceptions:
|
||||
|
||||
200 - 399: Valid response, no exception
|
||||
400: ActiveResource::ResourceInvalid (automatically caught by ARes validation)
|
||||
404: ActiveResource::ResourceNotFound
|
||||
409: ActiveResource::ResourceConflict
|
||||
401 - 499: ActiveResource::ClientError
|
||||
500 - 599: ActiveResource::ServerError
|
||||
|
||||
|
||||
=== Authentication
|
||||
|
||||
Many REST apis will require username/password authentication, usually in the form of
|
||||
Http authentication. This can easily be specified by putting the username and password
|
||||
in the Url of the ARes site:
|
||||
|
||||
class Person < ActiveResource::Base
|
||||
self.site = "http://ryan:password@api.people.com:3000/"
|
||||
end
|
||||
|
||||
For obvious reasons it is best if such services are available over https.
|
@ -8,6 +8,7 @@ class Base
|
||||
cattr_accessor :logger
|
||||
|
||||
class << self
|
||||
# Gets the URI of the resource's site
|
||||
def site
|
||||
if defined?(@site)
|
||||
@site
|
||||
@ -16,20 +17,24 @@ def site
|
||||
end
|
||||
end
|
||||
|
||||
# Set the URI for the REST resources
|
||||
def site=(site)
|
||||
@connection = nil
|
||||
@site = create_site_uri_from(site)
|
||||
end
|
||||
|
||||
# Base connection to remote service
|
||||
def connection(refresh = false)
|
||||
@connection = Connection.new(site) if refresh || @connection.nil?
|
||||
@connection
|
||||
end
|
||||
|
||||
attr_accessor_with_default(:element_name) { to_s.underscore }
|
||||
attr_accessor_with_default(:collection_name) { element_name.pluralize }
|
||||
attr_accessor_with_default(:primary_key, 'id')
|
||||
attr_accessor_with_default(:element_name) { to_s.underscore } #:nodoc:
|
||||
attr_accessor_with_default(:collection_name) { element_name.pluralize } #:nodoc:
|
||||
attr_accessor_with_default(:primary_key, 'id') #:nodoc:
|
||||
|
||||
# Gets the resource prefix
|
||||
# prefix/collectionname/1.xml
|
||||
def prefix(options={})
|
||||
default = site.path
|
||||
default << '/' unless default[-1..-1] == '/'
|
||||
@ -37,6 +42,8 @@ def prefix(options={})
|
||||
prefix(options)
|
||||
end
|
||||
|
||||
# Sets the resource prefix
|
||||
# prefix/collectionname/1.xml
|
||||
def prefix=(value = '/')
|
||||
prefix_call = value.gsub(/:\w+/) { |key| "\#{options[#{key}]}" }
|
||||
instance_eval <<-end_eval, __FILE__, __LINE__
|
||||
@ -48,10 +55,10 @@ def prefix(options={}) "#{prefix_call}" end
|
||||
raise
|
||||
end
|
||||
|
||||
alias_method :set_prefix, :prefix=
|
||||
alias_method :set_prefix, :prefix= #:nodoc:
|
||||
|
||||
alias_method :set_element_name, :element_name=
|
||||
alias_method :set_collection_name, :collection_name=
|
||||
alias_method :set_element_name, :element_name= #:nodoc:
|
||||
alias_method :set_collection_name, :collection_name= #:nodoc:
|
||||
|
||||
def element_path(id, options = {})
|
||||
"#{prefix(options)}#{collection_name}/#{id}.xml#{query_string(options)}"
|
||||
@ -61,8 +68,9 @@ def collection_path(options = {})
|
||||
"#{prefix(options)}#{collection_name}.xml#{query_string(options)}"
|
||||
end
|
||||
|
||||
alias_method :set_primary_key, :primary_key=
|
||||
alias_method :set_primary_key, :primary_key= #:nodoc:
|
||||
|
||||
# Core method for finding resources. Used similarly to ActiveRecord's find method.
|
||||
# Person.find(1) # => GET /people/1.xml
|
||||
# StreetAddress.find(1, :person_id => 1) # => GET /people/1/street_addresses/1.xml
|
||||
def find(*arguments)
|
||||
@ -80,7 +88,7 @@ def delete(id)
|
||||
connection.delete(element_path(id))
|
||||
end
|
||||
|
||||
# True if the resource is found.
|
||||
# Evalutes to <tt>true</tt> if the resource is found.
|
||||
def exists?(id, options = {})
|
||||
id && !find_single(id, options).nil?
|
||||
rescue ActiveResource::ResourceNotFound
|
||||
@ -88,16 +96,19 @@ def exists?(id, options = {})
|
||||
end
|
||||
|
||||
private
|
||||
# Find every resource.
|
||||
def find_every(options)
|
||||
collection = connection.get(collection_path(options)) || []
|
||||
collection.collect! { |element| new(element, options) }
|
||||
end
|
||||
|
||||
# Find a single resource.
|
||||
# { :person => person1 }
|
||||
def find_single(scope, options)
|
||||
new(connection.get(element_path(scope, options)), options)
|
||||
end
|
||||
|
||||
# Accepts a URI and creates the site URI from that.
|
||||
def create_site_uri_from(site)
|
||||
site.is_a?(URI) ? site.dup : URI.parse(site)
|
||||
end
|
||||
@ -106,6 +117,7 @@ def prefix_parameters
|
||||
@prefix_parameters ||= prefix_source.scan(/:\w+/).map { |key| key[1..-1].to_sym }.to_set
|
||||
end
|
||||
|
||||
# Builds the query string for the request.
|
||||
def query_string(options)
|
||||
# Omit parameters which appear in the URI path.
|
||||
query_params = options.reject { |key, value| prefix_parameters.include?(key) }
|
||||
@ -129,8 +141,8 @@ def query_string(options)
|
||||
end
|
||||
end
|
||||
|
||||
attr_accessor :attributes
|
||||
attr_accessor :prefix_options
|
||||
attr_accessor :attributes #:nodoc:
|
||||
attr_accessor :prefix_options #:nodoc:
|
||||
|
||||
def initialize(attributes = {}, prefix_options = {})
|
||||
@attributes = {}
|
||||
@ -138,19 +150,22 @@ def initialize(attributes = {}, prefix_options = {})
|
||||
@prefix_options = prefix_options
|
||||
end
|
||||
|
||||
# Is the resource a new object?
|
||||
def new?
|
||||
id.nil?
|
||||
end
|
||||
|
||||
# Get the id of the object.
|
||||
def id
|
||||
attributes[self.class.primary_key]
|
||||
end
|
||||
|
||||
# Set the id of the object.
|
||||
def id=(id)
|
||||
attributes[self.class.primary_key] = id
|
||||
end
|
||||
|
||||
# True if and only if +other+ is the same object or is an instance of the same class, is not new?, and has the same id.
|
||||
# True if and only if +other+ is the same object or is an instance of the same class, is not +new?+, and has the same +id+.
|
||||
def ==(other)
|
||||
other.equal?(self) || (other.instance_of?(self.class) && !other.new? && other.id == id)
|
||||
end
|
||||
@ -166,19 +181,22 @@ def hash
|
||||
id.hash
|
||||
end
|
||||
|
||||
# Delegates to +create+ if a new object, +update+ if its old.
|
||||
def save
|
||||
new? ? create : update
|
||||
end
|
||||
|
||||
# Delete the resource.
|
||||
def destroy
|
||||
connection.delete(element_path)
|
||||
end
|
||||
|
||||
# True if this resource is found.
|
||||
# Evaluates to <tt>true</tt> if this resource is found.
|
||||
def exists?
|
||||
!new? && self.class.exists?(id, prefix_options)
|
||||
end
|
||||
|
||||
# Convert the resource to an XML string
|
||||
def to_xml(options={})
|
||||
attributes.to_xml({:root => self.class.element_name}.merge(options))
|
||||
end
|
||||
@ -215,17 +233,19 @@ def connection(refresh = false)
|
||||
self.class.connection(refresh)
|
||||
end
|
||||
|
||||
# Update the resource on the remote service.
|
||||
def update
|
||||
connection.put(element_path, to_xml)
|
||||
end
|
||||
|
||||
# Create (i.e., save to the remote service) the new resource.
|
||||
def create
|
||||
returning connection.post(collection_path, to_xml) do |response|
|
||||
self.id = id_from_response(response)
|
||||
end
|
||||
end
|
||||
|
||||
# takes a response from a typical create post and pulls the ID out
|
||||
# Takes a response from a typical create post and pulls the ID out
|
||||
def id_from_response(response)
|
||||
response['Location'][/\/([^\/]*?)(\.\w+)?$/, 1]
|
||||
end
|
||||
@ -239,10 +259,12 @@ def collection_path(options = nil)
|
||||
end
|
||||
|
||||
private
|
||||
# Tries to find a resource for a given collection name; if it fails, then the resource is created
|
||||
def find_or_create_resource_for_collection(name)
|
||||
find_or_create_resource_for(name.to_s.singularize)
|
||||
end
|
||||
|
||||
# Tries to find a resource for a given name; if it fails, then the resource is created
|
||||
def find_or_create_resource_for(name)
|
||||
resource_name = name.to_s.camelize
|
||||
resource_name.constantize
|
||||
@ -253,7 +275,7 @@ def find_or_create_resource_for(name)
|
||||
resource
|
||||
end
|
||||
|
||||
def method_missing(method_symbol, *arguments)
|
||||
def method_missing(method_symbol, *arguments) #:nodoc:
|
||||
method_name = method_symbol.to_s
|
||||
|
||||
case method_name.last
|
||||
|
@ -24,7 +24,7 @@ class ResourceConflict < ClientError; end # 409 Conflict
|
||||
|
||||
class ServerError < ConnectionError; end # 5xx Server Error
|
||||
|
||||
|
||||
# Class to handle connections to remote services.
|
||||
class Connection
|
||||
attr_reader :site
|
||||
|
||||
@ -44,27 +44,37 @@ def initialize(site)
|
||||
self.site = site
|
||||
end
|
||||
|
||||
# Set URI for remote service.
|
||||
def site=(site)
|
||||
@site = site.is_a?(URI) ? site : URI.parse(site)
|
||||
end
|
||||
|
||||
# Execute a GET request.
|
||||
# Used to get (find) resources.
|
||||
def get(path)
|
||||
from_xml_data(Hash.from_xml(request(:get, path, build_request_headers).body).values.first)
|
||||
end
|
||||
|
||||
# Execute a DELETE request (see HTTP protocol documentation if unfamiliar).
|
||||
# Used to delete resources.
|
||||
def delete(path)
|
||||
request(:delete, path, build_request_headers)
|
||||
end
|
||||
|
||||
# Execute a PUT request (see HTTP protocol documentation if unfamiliar).
|
||||
# Used to update resources.
|
||||
def put(path, body = '')
|
||||
request(:put, path, body, build_request_headers)
|
||||
end
|
||||
|
||||
# Execute a POST request.
|
||||
# Used to create new resources.
|
||||
def post(path, body = '')
|
||||
request(:post, path, body, build_request_headers)
|
||||
end
|
||||
|
||||
private
|
||||
# Makes request to remote service.
|
||||
def request(method, path, *arguments)
|
||||
logger.info "#{method.to_s.upcase} #{site.scheme}://#{site.host}:#{site.port}#{path}" if logger
|
||||
result = nil
|
||||
@ -73,6 +83,7 @@ def request(method, path, *arguments)
|
||||
handle_response(result)
|
||||
end
|
||||
|
||||
# Handles response and error codes from remote service.
|
||||
def handle_response(response)
|
||||
case response.code.to_i
|
||||
when 200...400
|
||||
@ -92,6 +103,8 @@ def handle_response(response)
|
||||
end
|
||||
end
|
||||
|
||||
# Creates new (or uses currently instantiated) Net::HTTP instance for communication with
|
||||
# remote service and resources.
|
||||
def http
|
||||
unless @http
|
||||
@http = Net::HTTP.new(@site.host, @site.port)
|
||||
@ -102,15 +115,17 @@ def http
|
||||
@http
|
||||
end
|
||||
|
||||
# Builds headers for request to remote service.
|
||||
def build_request_headers
|
||||
authorization_header.update(self.class.default_header)
|
||||
end
|
||||
|
||||
# Sets authorization header; authentication information is pulled from credentials provided with site URI.
|
||||
def authorization_header
|
||||
(@site.user || @site.password ? { 'Authorization' => 'Basic ' + ["#{@site.user}:#{ @site.password}"].pack('m').delete("\r\n") } : {})
|
||||
end
|
||||
|
||||
def logger
|
||||
def logger #:nodoc:
|
||||
ActiveResource::Base.logger
|
||||
end
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
require 'active_resource/connection'
|
||||
|
||||
module ActiveResource
|
||||
class InvalidRequestError < StandardError; end
|
||||
class InvalidRequestError < StandardError; end #:nodoc:
|
||||
|
||||
class HttpMock
|
||||
class Responder
|
||||
|
@ -1,4 +1,13 @@
|
||||
module ActiveResource
|
||||
# Class that allows a connection to a remote resource.
|
||||
# Person = ActiveResource::Struct.new do |p|
|
||||
# p.uri "http://www.mypeople.com/people"
|
||||
# p.credentials :username => "mycreds", :password => "wordofpassage"
|
||||
# end
|
||||
#
|
||||
# person = Person.find(1)
|
||||
# person.name = "David"
|
||||
# person.save!
|
||||
class Struct
|
||||
def self.create
|
||||
Class.new(Base)
|
||||
|
@ -1,7 +1,9 @@
|
||||
module ActiveResource
|
||||
class ResourceInvalid < ClientError
|
||||
class ResourceInvalid < ClientError #:nodoc:
|
||||
end
|
||||
|
||||
# Active Resource validation is reported to and from this object, which is used by Base#save
|
||||
# to determine whether the object in a valid state to be saved. See usage example in Validations.
|
||||
class Errors
|
||||
include Enumerable
|
||||
attr_reader :errors
|
||||
@ -100,6 +102,38 @@ def from_xml(xml)
|
||||
end
|
||||
end
|
||||
|
||||
# Module to allow validation of ActiveResource objects, which are implemented by overriding +Base#validate+ or its variants.
|
||||
# Each of these methods can inspect the state of the object, which usually means ensuring that a number of
|
||||
# attributes have a certain value (such as not empty, within a given range, matching a certain regular expression). For example:
|
||||
#
|
||||
# class Person < ActiveResource::Base
|
||||
# self.site = "http://www.localhost.com:3000/"
|
||||
# protected
|
||||
# def validate
|
||||
# errors.add_on_empty %w( first_name last_name )
|
||||
# errors.add("phone_number", "has invalid format") unless phone_number =~ /[0-9]*/
|
||||
# end
|
||||
#
|
||||
# def validate_on_create # is only run the first time a new object is saved
|
||||
# unless valid_member?(self)
|
||||
# errors.add("membership_discount", "has expired")
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# def validate_on_update
|
||||
# errors.add_to_base("No changes have occurred") if unchanged_attributes?
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# person = Person.new("first_name" => "Jim", "phone_number" => "I will not tell you.")
|
||||
# person.save # => false (and doesn't do the save)
|
||||
# person.errors.empty? # => false
|
||||
# person.errors.count # => 2
|
||||
# person.errors.on "last_name" # => "can't be empty"
|
||||
# person.attributes = { "last_name" => "Halpert", "phone_number" => "555-5555" }
|
||||
# person.save # => true (and person is now saved to the remote service)
|
||||
#
|
||||
# An Errors object is automatically created for every resource.
|
||||
module Validations
|
||||
def self.included(base) # :nodoc:
|
||||
base.class_eval do
|
||||
|
Loading…
Reference in New Issue
Block a user