lib/jekyll-twitter-plugin.rb in jekyll-twitter-plugin-1.4.0 vs lib/jekyll-twitter-plugin.rb in jekyll-twitter-plugin-2.0.0

- old
+ new

@@ -1,25 +1,28 @@ # frozen_string_literal: true require "fileutils" -require "twitter" +require "net/http" +require "uri" +require "ostruct" +require "json" +require "digest" ## # A Liquid tag plugin for Jekyll that renders Tweets from Twitter API. # https://github.com/rob-murray/jekyll-twitter-plugin # module TwitterJekyll - MissingApiKeyError = Class.new(StandardError) - TwitterSecrets = Struct.new(:consumer_key, :consumer_secret, :access_token, :access_token_secret) do - def self.build(source, keys) - new(*source.values_at(*keys)) - end - end - CONTEXT_API_KEYS = %w(consumer_key consumer_secret access_token access_token_secret).freeze - ENV_API_KEYS = %w(TWITTER_CONSUMER_KEY TWITTER_CONSUMER_SECRET TWITTER_ACCESS_TOKEN TWITTER_ACCESS_TOKEN_SECRET).freeze - TWITTER_STATUS_URL = %r{\Ahttps?://twitter\.com/(:#!\/)?\w+/status(es)?/\d+}i - REFER_TO_README = "Please see 'https://github.com/rob-murray/jekyll-twitter-plugin' for usage." + # TODO: remove after deprecation cycle + CONTEXT_API_KEYS = %w(consumer_key consumer_secret access_token access_token_secret).freeze + ENV_API_KEYS = %w(TWITTER_CONSUMER_KEY TWITTER_CONSUMER_SECRET TWITTER_ACCESS_TOKEN TWITTER_ACCESS_TOKEN_SECRET).freeze + REFER_TO_README = "Please see 'https://github.com/rob-murray/jekyll-twitter-plugin' for usage.".freeze + LIBRARY_VERSION = "jekyll-twitter-plugin-v2.0.0".freeze + REQUEST_HEADERS = { "User-Agent" => LIBRARY_VERSION }.freeze + # Cache class that writes to filesystem + # TODO: Do i really need to cache? + # @api private class FileCache def initialize(path) @cache_folder = File.expand_path path FileUtils.mkdir_p @cache_folder end @@ -47,215 +50,232 @@ def cache_filename(cache_key) "#{cache_key}.cache" end end + # Cache class that does nothing + # @api private class NullCache def initialize(*_args); end def read(_key); end def write(_key, _data); end end - module Cacheable - def cache_key - Digest::MD5.hexdigest("#{self.class.name}-#{key}") - end + # Wrapper around an API + # @api private + class ApiClient + # Perform API request; return hash with html content + def fetch(api_request) + uri = api_request.to_uri + response = Net::HTTP.start(uri.host, use_ssl: api_request.ssl?) do |http| + http.read_timeout = 5 + http.open_timeout = 5 + http.get uri.request_uri, REQUEST_HEADERS + end - def key; end - end + handle_response(api_request, response) - class TwitterApi - ERRORS_TO_IGNORE = [Twitter::Error::NotFound, Twitter::Error::Forbidden].freeze - - attr_reader :error - - def initialize(client, params) - @client = client - @status_url = params.shift - parse_args(params) + rescue Timeout::Error => e + ErrorResponse.new(api_request, e.class.name).to_h end - def fetch; end - private - def id_from_status_url(url) - Regexp.last_match[1] if url.to_s =~ %r{([^\/]+$)} + def handle_response(api_request, response) + case response + when Net::HTTPSuccess + JSON.parse(response.body) + else + ErrorResponse.new(api_request, response.message).to_h + end end + end - def find_tweet(id) - return unless id - - @client.status(id.to_i) - rescue *ERRORS_TO_IGNORE => e - @error = create_error(e) - return nil + # @api private + ErrorResponse = Struct.new(:request, :message) do + def html + "<p>There was a '#{message}' error fetching URL: '#{request.entity_url}'</p>" end - def parse_args(args) - @params ||= begin - args.each_with_object({}) do |arg, params| - k, v = arg.split("=").map(&:strip) - if k && v - v = Regexp.last_match[1] if v =~ /^'(.*)'$/ - params[k] = v - end - end - end + def to_h + { html: html } end - - def create_error(exception) - ErrorResponse.new("There was a '#{exception.class.name}' error fetching Tweet '#{@status_url}'") - end end - class Oembed < TwitterApi - include TwitterJekyll::Cacheable + # Holds the URI were going to request with any parameters + # @api private + ApiRequest = Struct.new(:entity_url, :params) do + TWITTER_API_URL = "https://publish.twitter.com/oembed".freeze - def fetch - tweet_id = id_from_status_url(@status_url) + # Always; + def ssl? + true + end - if tweet = find_tweet(tweet_id) - # To work around a 'bug' in the Twitter gem modifying our hash we pass in - # a copy otherwise our cache key is altered. - @client.oembed tweet, @params.dup - else - error + # Return a URI for Twitter API with query params + def to_uri + URI.parse(TWITTER_API_URL).tap do |uri| + uri.query = URI.encode_www_form url_params end end - private - - def key - format("%s-%s", @status_url, @params.to_s) + # A cache key applicable to the current request with params + def cache_key + Digest::MD5.hexdigest("#{self.class.name}-#{unique_request_key}") end - end - class ErrorResponse - attr_reader :error + private - def initialize(error) - @error = error + def url_params + params.merge(url: entity_url) end - def html - "<p>#{@error}</p>" + def unique_request_key + format("%s-%s", entity_url, params.to_s) end - - def to_h - { html: html } - end end + # Class to respond to Jekyll tag; entry point to library + # @api public class TwitterTag < Liquid::Tag - ERROR_BODY_TEXT = "<p>Tweet could not be processed</p>" - DEFAULT_API_TYPE = "oembed" + ERROR_BODY_TEXT = "<p>Tweet could not be processed</p>".freeze + OEMBED_ARG = "oembed".freeze attr_writer :cache # for testing def initialize(_name, params, _tokens) super - @api_type, @params = parse_params(params) + @api_request = parse_params(params) end + # Class that implements caching strategy + # @api private def self.cache_klass FileCache end + # Return html string for Jekyll engine + # @api public def render(context) - secrets = find_secrets!(context) - create_twitter_rest_client(secrets) - api_client = create_api_client(@api_type, @params) - response = cached_response(api_client) || live_response(api_client) + api_secrets_deprecation_warning(context) # TODO: remove after deprecation cycle + response = cached_response || live_response html_output_for(response) end private def cache @cache ||= self.class.cache_klass.new("./.tweet-cache") end + def api_client + @api_client ||= ApiClient.new + end + + # Return Twitter response or error html + # @api private def html_output_for(response) body = (response.html if response) || ERROR_BODY_TEXT - "<div class='embed twitter'>#{body}</div>" + "<div class='jekyll-twitter-plugin'>#{body}</div>" end - def live_response(api_client) - if response = api_client.fetch - cache.write(api_client.cache_key, response) - response + # Return response from API and write to cache + # @api private + def live_response + if response = api_client.fetch(@api_request) + cache.write(@api_request.cache_key, response) + build_response(response) end end - def cached_response(api_client) - response = cache.read(api_client.cache_key) - OpenStruct.new(response) unless response.nil? + # Return response cache if present, otherwise nil + # @api private + def cached_response + response = cache.read(@api_request.cache_key) + build_response(response) unless response.nil? end + # Return an `ApiRequest` with the url and arguments + # @api private def parse_params(params) args = params.split(/\s+/).map(&:strip) + invalid_args!(args) unless args.any? - case args[0] - when DEFAULT_API_TYPE - api_type, *api_args = args - [api_type, api_args] - when TWITTER_STATUS_URL - [DEFAULT_API_TYPE, args] - else - invalid_args!(args) + if args[0].to_s == OEMBED_ARG # TODO: remove after deprecation cycle + arguments_deprecation_warning(args) + args.shift end - end - def create_api_client(api_type, params) - klass_name = api_type.capitalize - api_client_klass = TwitterJekyll.const_get(klass_name) - api_client_klass.new(@twitter_client, params) + url, *api_args = args + ApiRequest.new(url, parse_args(api_args)) end - def create_twitter_rest_client(secrets) - @twitter_client = Twitter::REST::Client.new do |config| - config.consumer_key = secrets.consumer_key - config.consumer_secret = secrets.consumer_secret - config.access_token = secrets.access_token - config.access_token_secret = secrets.access_token_secret + # Transform 'a=b x=y' tag arguments into { "a" => "b", "x" => "y" } + # @api private + def parse_args(args) + args.each_with_object({}) do |arg, params| + k, v = arg.split("=").map(&:strip) + if k && v + v = Regexp.last_match[1] if v =~ /^'(.*)'$/ + params[k] = v + end end end - def find_secrets!(context) - extract_twitter_secrets_from_context(context) || extract_twitter_secrets_from_env || missing_keys! + # Format a response hash + # @api private + def build_response(h) + OpenStruct.new(h) end - def extract_twitter_secrets_from_context(context) + # TODO: remove after deprecation cycle + def arguments_deprecation_warning(args) + warn "#{LIBRARY_VERSION}: Passing '#{OEMBED_ARG}' as the first argument is not required anymore. This will result in an error in future versions.\nCalled with #{args.inspect}" + end + + # TODO: remove after deprecation cycle + def api_secrets_deprecation_warning(context) + warn_if_twitter_secrets_in_context(context) || warn_if_twitter_secrets_in_env + end + + # TODO: remove after deprecation cycle + def warn_if_twitter_secrets_in_context(context) twitter_secrets = context.registers[:site].config.fetch("twitter", {}) return unless store_has_keys?(twitter_secrets, CONTEXT_API_KEYS) - TwitterSecrets.build(twitter_secrets, CONTEXT_API_KEYS) + warn_secrets_in_project("Jekyll _config.yml") end - def extract_twitter_secrets_from_env + # TODO: remove after deprecation cycle + def warn_if_twitter_secrets_in_env return unless store_has_keys?(ENV, ENV_API_KEYS) - TwitterSecrets.build(ENV, ENV_API_KEYS) + warn_secrets_in_project("ENV") end + # TODO: remove after deprecation cycle + def warn_secrets_in_project(source) + warn "#{LIBRARY_VERSION}: Found Twitter API keys in #{source}, this library does not require these keys anymore. You can remove these keys, if used for another library then ignore this message." + end + + # TODO: remove after deprecation cycle def store_has_keys?(store, keys) keys.all? { |required_key| store.key?(required_key) } end - def missing_keys! - raise MissingApiKeyError, "Twitter API keys not found. You can specify these in Jekyll config or ENV. #{REFER_TO_README}" - end - + # Raise error for invalid arguments + # @api private def invalid_args!(arguments) formatted_args = Array(arguments).join(" ") raise ArgumentError, "Invalid arguments '#{formatted_args}' passed to 'jekyll-twitter-plugin'. #{REFER_TO_README}" end end + # Specialization of TwitterTag without any caching + # @api public class TwitterTagNoCache < TwitterTag def self.cache_klass NullCache end end