четверг, апреля 19, 2007

Реализация Pingback механизма


Что такое pingback хорошо описано здесь. А также в спецификации.

Задача состоит в том что есть события (Event), которые имеют комментарии, а также должны отслеживать pingback ссылки, чем мы и займемся. Весь код приводится без купюр, для того чтобы его можно было использовать с небольшими модификациями если понадобится.

Сгенерируем web сервис
ruby script\generate ws pingback ping

Объявление и реализация web сервиса
# app\apis\event_pingback_api.rb
class EventPingbackApi < ActionWebService::API::Base
inflect_names false

api_method :ping,
:expects => [ {:sourceURI => :string}, {:targetURI => :string}],
:returns => [:string]

end


class EventPingbackService < ActionWebService::Base
web_service_api EventPingbackApi

def ping(source_uri, target_uri)
event = find_event_by_uri(target_uri)
EventPingbackComment.register! event, source_uri, target_uri
return "success"
rescue
raise XMLRPC::FaultException.new(0, $!.class.to_s.underscore.humanize)
end

protected
def find_event_by_uri(uri)
uri_params = ::ActionController::Routing::Routes.recognize_path(URI.parse(uri).path, {})
event = Event.find_by_id(uri_params[:id])
raise EventPingbackComment::TargetNoExist unless event
event
end

end

По спецификации, ошибка может быть описана или детально, каждая со своим кодом, или с общим кодом 0, я остановился на простом варианте (raise XMLRPC::FaultException.new(0)).


web сервис общается с внешним миром через контроллер
# app\controllers\event_pingback_controller.rb
class EventPingbackController < ApplicationController
session :off

web_service_dispatching_mode :layered

web_service :pingback, EventPingbackService.new

#web_service_scaffold :invoke

end

# To test with Fiddler
# POST http://localhost:3000/event_pingback/api
# HEADERS:
# Content-Type: text/xml
# User-Agent: Incutio XML-RPC -- WordPress/2.1.3
# Host: thisishappening.local:3000
# Content-Length: 293
#
# BODY:
# <?xml version="1.0"?>
# <methodCall>
# <methodName>pingback.ping</methodName>
# <params>
# <param><value><string>http://localhost/blogs/wordpress/?p=10</string></value></param>
# <param><value><string>http://localhost:3000/events/show/6</string></value></param>
# </params></methodCall>


Обратите внимание на web_service_dispatching_mode :layered, это необходимо чтобы метод мог быть вызван по имени pingback.ping, URL на web сервис будет в этом случае http://my.app.com/PATH/TO/CONTROLLER/api.


теперь pingback запись в нашей системе
# app\models\event_pingback_comment.rb
require 'hpricot'
require 'open-uri'

class EventPingbackComment < EventGeneralComment
class PingbackError < StandardError; end
class AlreadyRegistered < PingbackError; end
class SourceNoLinkedToTarget < PingbackError; end
class TargetNoExist < PingbackError; end

validates_uniqueness_of :source_uri, :scope => :event_id

# register ping is the system as comment
def self.register!(event, source_uri, target_uri)
raise AlreadyRegistered if EventPingbackComment.count(:conditions => {:event_id => event.id, :source_uri => source_uri}) > 0
source_body = load_page_content(source_uri)
raise SourceNoLinkedToTarget unless source_body.include? target_uri

# extract some info from source page
doc = Hpricot(source_body)
title = doc.at('title') ? cleanup_title(doc.at('title').inner_text) : nil

EventPingbackComment.create! :event => event,
:source_uri => source_uri,
:source_title => title,
:body => extract_excerpt(doc, target_uri)
end

def pingback?
true
end

protected
def self.load_page_content(uri)
open(uri).read
end

def self.cleanup_title(text)
text.gsub(/<\/?[^>]*>/, "").gsub(/\s{2,}/, ' ').strip
end

def self.extract_excerpt(doc, target_uri)
link = doc.at(%Q{a[@href="#{target_uri}"]}) # that consruct no catch urls with port, like http://domain:3000, be careful when test
return '' unless link

link_text = link.inner_text
#prevent really long link text
link_text = link_text[0, 100]+'...' if link_text.size > 100

# since in text can be several links to our page, substitute first found with marker
link.swap('PINGBACK_TARGET_URI')
# get text only from parent HTML element
text = link.parent.inner_text
# remove new lines
text.gsub!(/\s*\n\s*/, ' ')
# cut text to 100 chars before and 100 after, rounded to full word
text.gsub!(/.*?\s(.{0,100}PINGBACK_TARGET_URI.{0,100})\s.*/m, '\1')
# insert link text back
text.gsub!("PINGBACK_TARGET_URI", link_text)
text.strip
end
end

добавим именованный путь на вебсервис
  # config\routes.rb
map.event_pingback "event_pingback/api", :controller => 'event_pingback', :action => 'api'
map.events "events/:action/:id", :controller => 'events'

добавим заголовоки к странице события
  # app\controllers\events_controller.rb
def show
response.headers['X-Pingback'] = event_pingback_url
end

<!-- app\views\events\show.rhtml -->
<% content_for :head_top do %>
<link rel="pingback" href="<%= event_pingback_url %>" />
<% end %>
<!-- right column -->

HRML для pingback коментария будет следующим
      <p class="user-post-header">Pingback by <%= link_to (comment.source_title || 'Anonymous'), comment.source_uri %> at <%= comment.created_at.to_s(:time) %>:</p>
<% unless comment.body.blank? %>
<p>[...] <%= h comment.body %> [...]</p>
<% end %>
<% end %>

Тестирование всего этого дела, проводилось с использанием Wordpress 2.1.
На этом основная часть закончена, ниже будут приведены тесты.

Тесты
поставим глобальную заглушку, чтобы при тестах не делать запросов наружу
# test\mocks\test\event_pingback_comment.rb
require 'app/models/event_pingback_comment'

class EventPingbackComment

protected
def self.load_page_content(uri)
'<head><title> Source Title </title><head>
<body>
<div> link to <a href="http://local.com/events/show/1">Event One</a> </div>
<body>'
end
# use EventPingbackComment.stubs(:load_page_content).returns('text')
# to override this stub
end

тесты на то что коментарий создается правильно
# test\unit\event_pingback_comment_test.rb
require File.dirname(__FILE__) + '/../test_helper'

class EventPingbackCommentTest < Test::Unit::TestCase

fixtures :events, :event_comments

def test_should_create
assert_difference EventPingbackComment do
EventPingbackComment.register! events(:one), "http://source.com/blog/post", "http://local.com/events/show/1"
end
end

def test_should_extract_title_from_source_page
create_comment
assert_equal "Source Title", @comment.source_title
end

def test_should_no_allow_nested_tags_in_title_from_source_page
EventPingbackComment.stubs(:load_page_content).returns('<title> Source <script><script>alert(1)</script> Title </title><div>link to <a href="http://local.com/events/show/1">Event One</div>')
create_comment
assert_equal "Source alert(1) Title", @comment.source_title
end

def test_should_no_fail_for_source_page_without_title
EventPingbackComment.stubs(:load_page_content).returns('<div>link to <a href="http://local.com/events/show/1">Event One</div>')
create_comment
assert_equal nil, @comment.source_title
end

def test_should_no_create_if_already_referenced
assert_no_difference EventPingbackComment do
assert_raise(EventPingbackComment::AlreadyRegistered) do
EventPingbackComment.register! events(:one), "http://source.com/blog/oldpost", "http://local.com/events/show/1"
end
end
end

def test_should_no_create_if_link_no_exist_on_source_page
EventPingbackComment.stubs(:load_page_content).returns('no link')
assert_no_difference EventPingbackComment do
assert_raise(EventPingbackComment::SourceNoLinkedToTarget) do
EventPingbackComment.register! events(:one), "http://source.com/blog/post", "http://local.com/events/show/1"
end
end
end

def test_should_extract_excerpt_from_source_page
create_comment
assert_equal "link to Event One", @comment.body
end

def test_source_page_extract_excerpt_should_get_100_characters_before_and_100_after
source_body = '<div> Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Nam lectus justo, porttitor ac, ullamcorper ac, cursus in, ante.
<a href="http://local.com/events/show/1">Event One</a>
Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Nam lectus justo, porttitor ac, ullamcorper ac, cursus in, ante.
</div>'
# should extract words (no words clipping)
before = 'consectetuer adipiscing elit. Nam lectus justo, porttitor ac, ullamcorper ac, cursus in, ante.'
after = 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Nam lectus justo, porttitor ac,'
assert_equal "#{before} Event One #{after}", EventPingbackComment.extract_excerpt(Hpricot(source_body), 'http://local.com/events/show/1')
end

def test_source_page_extract_excerpt_should_prevent_really_long_link_text
source_body = '<div> <a href="http://local.com/events/show/1">Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Nam lectus justo, porttitor ac, ullamcorper ac, cursus in, ante.</a> </div>'
assert_equal "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Nam lectus justo, porttitor ac, ullamcorpe...", EventPingbackComment.extract_excerpt(Hpricot(source_body), 'http://local.com/events/show/1')
end

def test_source_page_extract_excerpt_should_be_empty_for_pages_without_valid_link
source_body = '<div> Some Text http://local.com/events/show/1 end of text </div>'
assert_equal '', EventPingbackComment.extract_excerpt(Hpricot(source_body), 'http://local.com/events/show/1')
end

private
def create_comment
@comment = EventPingbackComment.register! events(:one), "http://source.com/blog/post", "http://local.com/events/show/1"
end
end

тесты на вебсервис
# test\functional\event_pingback_api_test.rb
require File.dirname(__FILE__) + '/../test_helper'
require 'event_pingback_controller'

class EventPingbackController; def rescue_action(e) raise e end; end

class EventPingbackControllerApiTest < Test::Unit::TestCase
fixtures :all

def setup
@controller = EventPingbackController.new
@request = ActionController::TestRequest.new
@response = ActionController::TestResponse.new
end

def test_ping_to_event
assert_success do
@result = invoke_layered :pingback, :ping, "http://source.com/blog/post", "http://local.com/events/show/1"
end
end

def test_ping_to_event_by_shortcut
EventPingbackComment.stubs(:load_page_content).returns('<div>link to <a href="http://local.com/RangeEvent">Event One</div>')
assert_success do
@result = invoke_layered :pingback, :ping, "http://source.com/blog/post", "http://local.com/RangeEvent"
end
end

def test_ping_to_event_that_not_exist
assert_failure(XMLRPC::FaultException) do
@result = invoke_layered :pingback, :ping, "http://source.com/blog/post", "http://local.com/events/show/1000"
end
end

def test_ping_to_event_to_shortcut_that_not_exist
assert_failure(XMLRPC::FaultException) do
@result = invoke_layered :pingback, :ping, "http://source.com/blog/post", "http://local.com/NotExist"
end
end

def assert_success
assert_difference EventPingbackComment do
yield
# If the pingback request is successful, then the return value MUST be a single string, containing as much information as the server deems useful.
assert_kind_of String, @result
assert @result.size > 0
end
end

def assert_failure(error_class = EventPingbackComment::PingbackError)
assert_no_difference EventPingbackComment do
assert_raise error_class do
yield
end
end
end

end

тесты на то что страница события имеет неоходимые заголовки
# test\integration\event_pingback_test.rb
require "#{File.dirname(__FILE__)}/../test_helper"

# see http://www.hixie.ch/specs/pingback/pingback for pingback spec.
class EventPingbackTest < ActionController::IntegrationTest
fixtures :all

def test_events_should_has_x_pingback_header_and_pingback_link
get 'events/show/1'
assert_response :success
assert_not_nil response.headers['X-Pingback']
# if header not found, link rel="pingback" element will be checked
# worpress 2.1 by default check only first 2048 bytes
assert_match %r{<link rel="pingback" href="([^"]+)" ?/?>}, response.body[0, 2048]
end

def test_other_pages_should_not_be_pingback_enabled
get '/'
assert_response :success
assert_nil response.headers['X-Pingback']
assert_no_match %r{<link rel="pingback" href="([^"]+)" ?/?>}, response.body
end
end

Ярлыки: , ,

Комментарии: 0:

Отправить комментарий

Подпишитесь на каналы Комментарии к сообщению [Atom]

<< Главная страница