Wednesday, March 30, 2011

OAuth и Twitter, осилим вместе!

OAuth набирает обороты, все чаще можно встретить его модификации в популярных сервисах, таких как Flickr, Twitter, Facebook и др. (Если честно, другие, с которыми я работал, я просто не видел OAuth, будем надеяться, что что-то изменилось).

Я бы хотел рассмотреть, в данном посте, интеграцию Twitter в ваше third-party application с использованием OAuth и написанном на C#. На этот раз, это будет очень технический пост с большим количеством кода, сразу предупреждаю.

OAuth, что да как?!
Сразу оговорюсь, может я не прав с подходом и пониманием ситуации в целом, и я может очень критичен, НО, когда сервис пишет OAuth 2.0, то нормальный человек понимает, это OAuth 2.0, и в принципе в 90% случаев, пошлют вас посетить сайт http://oauth.net, но вы уж простите, там ведь черт ногу сломит, сидеть читать все это.

Twitter + OAuth
На примере Twitter мы имеем место где можно почитать про API и, в том числе, про OAuth - http://dev.twitter.com. Что дальше? Я советую пройти сюда для прочтения третьей части, это даст начальное понимание, в принципе. Дальше время писать код и имплементировать все это дело в приложение/класс/модуль, как вам угодно. И так, моя любимая картинка описывающая весь процесс получения магических токенов.


Логика простая и в более детальном рассмотрении не нуждается, а о том, как все это хозяйство описать и запускать, давайте рассмотрим ниже.

Заждались?
Я, как вы могли заметить, не больно люблю писать, так что давайте сразу к сути. Безупречность моего кода для меня нормальна, для кого-то может быть, не очень или ужасно. В тот момент времени в C#, я считал что все нормально, в принципе и сейчас так считаю, хотя разбить на несколько модулей это все можно было.

Consumer key & secret
Чтобы мы могли обращаться к Twitter API, нам потребуются Consumer Key и Consumer Secret, взять и зарегистрировать ваше личное приложение можно просто и быстро перейдя по Register an app далее или перейдя в Your apps вы получаете список ваших зарегистрированных приложений, а так же нужные ключи (Consumer Key, Consumer Secret).

Declarations
Перейдем к написанию части отвечающей за формирование подписи и включения всех необходимых частей для получения окончательного набора параметров OAuth пакета.

#region Properties

public enum Method { GET, POST };

#region Private

private const string ConsumerKey = "ВАШ_CONSUMER_KEY";
private const string ConsumerSecret = "ВАШ_CONSUMER_SECRET";
private readonly string Realm = string.Format(CultureInfo.InvariantCulture, "НашеСамоеКлассноеПриложение/{0}",
    System.Reflection.Assembly.GetAssembly(typeof(Twitter)).GetName().Version.ToString());

#endregion

#region Public

public string Token { get; set; }
public string TokenSecret { get; set; }

public Dictionary<string, string> Parameters { get; private set; }
public Dictionary<string, string> OAuthParameters { get; private set; }

public Uri RequestUri { get; set; }
public Method RequestMethod { get; set; }

// кто не понимает что это и зачем, это Singleton ну и wikipedia в помощь
private static Twitter instance;
public static Twitter Instance
{
    get
    {
        if (instance == null)
        {
            instance = new Twitter();
        }
        return instance;
    }
}

#endregion

#endregion

OAuth implementation
Надеюсь вы все еще тут, т.к. это только начало, сейчас опишем более менее кратко все необходимое для подписи пакетов OAuth.

#region OAuth Helper Methods

// очень важно, по сути ставим стандартный набор oauth_* после чего заполняем если есть public token и secret token и в конце формируем oauth signature (подпись)
public void SetupOAuth()
{
    OAuthParameters.Add("oauth_version", "1.0");
    OAuthParameters.Add("oauth_nonce", GenerateNonce());
    OAuthParameters.Add("oauth_timestamp", GenerateTimeStamp());
    OAuthParameters.Add("oauth_signature_method", "HMAC-SHA1");
    OAuthParameters.Add("oauth_consumer_key", ConsumerKey);
    OAuthParameters.Add("oauth_consumer_secret", ConsumerSecret);
    
    if (!string.IsNullOrEmpty(Token))
    {
        OAuthParameters.Add("oauth_token", Token);
    }
    
    if (!string.IsNullOrEmpty(TokenSecret))
    {
        OAuthParameters.Add("oauth_token_secret", TokenSecret);
    }
    
    var nonSecretParameters = (from p in this.OAuthParameters
                               where !(p.Key.EndsWith("_secret", StringComparison.OrdinalIgnoreCase) &&
                                       !p.Key.EndsWith("_verifier", StringComparison.OrdinalIgnoreCase))
                               select p).Union(this.Parameters);
    
    var urlForSigning = RequestUri;
    var signatureBaseString = string.Format(CultureInfo.InvariantCulture, "{0}&{1}&{2}",
                                            RequestMethod.ToString(), UrlEncode(NormalizeUrl(urlForSigning)), UrlEncode(nonSecretParameters));
    
    var key = string.Format(CultureInfo.InvariantCulture, "{0}&{1}", UrlEncode(ConsumerSecret), UrlEncode(TokenSecret));
    var hmacsha1 = new HMACSHA1(Encoding.ASCII.GetBytes(key));
    var signatureBytes = hmacsha1.ComputeHash(Encoding.ASCII.GetBytes(signatureBaseString));
    
    OAuthParameters.Add("oauth_signature", Convert.ToBase64String(signatureBytes));
}

// думаю в объяснения не нуждается, читаем Twitter API
public static string GenerateTimeStamp()
{
    var ts = DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, 0);
    return Convert.ToInt64(ts.TotalSeconds, CultureInfo.CurrentCulture).ToString(CultureInfo.CurrentCulture);
}

// думаю в объяснения не нуждается, читаем Twitter API
public static string GenerateNonce()
{
    return new Random().Next(123400, int.MaxValue).ToString("X", CultureInfo.InvariantCulture);
}

// вполне вероятно, зря скопипастил этот кусок, но все же...
public static string NormalizeUrl(Uri url)
{
    string normalizedUrl = string.Format(CultureInfo.InvariantCulture, "{0}://{1}", url.Scheme, url.Host);
    if (!((url.Scheme == "http" && url.Port == 80) || (url.Scheme == "https" && url.Port == 443)))
    {
        normalizedUrl += ":" + url.Port;
    }
    normalizedUrl += url.AbsolutePath;
    return normalizedUrl;
}

// параноя взяла свое, а если серьезно, просто не работает Twitter API если использовать родное преобразование
public static string UrlEncode(string value)
{
    if (string.IsNullOrEmpty(value))
    {
        return string.Empty;
    }
    
    value = HttpUtility.UrlEncode(value).Replace("+", "%20");
    
    value = Regex.Replace(value, "(%[0-9a-f][0-9a-f])", c => c.Value.ToUpper());
    
    value = value.Replace("(", "%28")
    .Replace(")", "%29")
    .Replace("$", "%24")
    .Replace("!", "%21")
    .Replace("*", "%2A")
    .Replace("'", "%27")
    .Replace("%7E", "~");
    
    return value;
}

// для облегчения жизни, ради этого и живем
private static string UrlEncode(IEnumerable<KeyValuePair<string, string>> parameters)
{
    var parameterString = new StringBuilder();
    
    var paramsSorted = from p in parameters
    orderby p.Key, p.Value
    select p;
    
    foreach (var item in paramsSorted)
    {
        if (parameterString.Length > 0)
        {
            parameterString.Append("&");
        }
        
        parameterString.Append(
                               string.Format(
                                             CultureInfo.InvariantCulture,
                                             "{0}={1}",
                                             UrlEncode(item.Key),
                                             UrlEncode(item.Value)));
    }
    
    return UrlEncode(parameterString.ToString());
}

// в принципе ничего страшного, сортируем, выбираем только публичные ключи и ставим наш Realm
public string GenerateAuthorizationHeader()
{
    var authHeaderBuilder = new StringBuilder();
    authHeaderBuilder.AppendFormat("OAuth realm=\"{0}\"", Realm);
    
    var sortedParameters = from p in this.OAuthParameters
    where p.Key.StartsWith("oauth_") &&
    !p.Key.EndsWith("_secret", StringComparison.OrdinalIgnoreCase) &&
    p.Key != "oauth_signature" &&
    p.Key != "oauth_verifier" &&
    !string.IsNullOrEmpty(p.Value)
    orderby p.Key, UrlEncode(p.Value)
    select p;
    
    foreach (var item in sortedParameters)
    {
        authHeaderBuilder.AppendFormat(
                                       ",{0}=\"{1}\"",
                                       UrlEncode(item.Key),
                                       UrlEncode(item.Value));
    }
    
    authHeaderBuilder.AppendFormat(",oauth_signature=\"{0}\"", UrlEncode(this.OAuthParameters["oauth_signature"]));
    
    return authHeaderBuilder.ToString();
}

#endregion

HTTP Requests
Для отправки и приема пакетов (текста по сути) необходимо все это поднять как можно повыше, т.е. привести к виду вот параметры, вот ответ. Ну что же, давайте постараемся.

#region Requests

// если имеем параметры и запрос get, нужно сформировать строку вида http://host/parts/?query=parameters
private void AddQueryStringParametersToUri()
{
    if (RequestMethod == Method.GET)
    {
        var requestParametersBuilder = new StringBuilder(RequestUri.AbsoluteUri);
        requestParametersBuilder.Append(RequestUri.Query.Length == 0 ? "?" : "&");
        foreach (KeyValuePair<string, string> item in Parameters)
        {
            requestParametersBuilder.AppendFormat("{0}={1}&", item.Key, Uri.EscapeDataString(item.Value));
        }
        if (requestParametersBuilder.Length == 0)
        {
            return;
        }
        requestParametersBuilder.Remove(requestParametersBuilder.Length - 1, 1);
        RequestUri = new Uri(requestParametersBuilder.ToString());
    }
}
// если имеем параметры и запрос POST, нужно записать в request stream все наши передаваемые параметры
private void AddFormFieldValuesToRequest(WebRequest request)
{
    if (RequestMethod == Method.POST)
    {
        request.ContentType = "application/x-www-form-urlencoded";

        var requestParametersBuilder = new StringBuilder();
        foreach (KeyValuePair<string, string> item in Parameters)
        {
            requestParametersBuilder.AppendFormat("{0}={1}&", item.Key, Uri.EscapeDataString(item.Value));
        }
        if (requestParametersBuilder.Length == 0)
        {
            return;
        }
        requestParametersBuilder.Remove(requestParametersBuilder.Length - 1, 1);
        var formData = Encoding.UTF8.GetBytes(requestParametersBuilder.ToString());

        request.ContentLength = formData.Length;
        var requestStream = request.GetRequestStream();
        requestStream.Write(formData, 0, formData.Length);
        requestStream.Close();
    }
}
// начинаем постройку нового запроса, указываем какой типа запроса и ссылку на соответствующую API
private void BuildRequest(Method method, string url)
{
    RequestUri = new Uri(url);
    RequestMethod = method;

    Parameters = new Dictionary<string, string>();
    OAuthParameters = new Dictionary<string, string>();

    if (!string.IsNullOrEmpty(RequestUri.Query))
    {
        foreach (Match item in Regex.Matches(RequestUri.Query, @"(?<key>[^&?=]+)=(?<value>[^&?=]+)"))
        {
            Parameters.Add(item.Groups["key"].Value, item.Groups["value"].Value);
        }
        RequestUri = new Uri(RequestUri.AbsoluteUri.Replace(RequestUri.Query, ""));
    }
}
// приступаем к выполнению построенного ранее запроса (RequestUri, RequestMethod, Parameters, OAuthParameters)
public string ExecuteRequest()
{
    SetupOAuth(); // заполняем все поля OAuth
    AddQueryStringParametersToUri(); // см. выше

    var request = (HttpWebRequest)WebRequest.Create(RequestUri);
    request.Method = RequestMethod.ToString();
    request.UserAgent = Realm;
    request.ServicePoint.Expect100Continue = false;
    request.Headers.Add("Authorization", GenerateAuthorizationHeader()); // не забываем о полях авторизации на стороне сервера

    AddFormFieldValuesToRequest(request);

    var response = request.GetResponse(); // выполняем запрос и получаем ответ
    var responseReader = new StreamReader(response.GetResponseStream());
    var responseData = responseReader.ReadToEnd(); // чтобы вернуть строку, в нашем случае XML Response
    response.Close();
    responseReader.Close();
    responseReader = null;
    request = null;

    return responseData;
}

// пропускаем постройку и сразу переходим к выполнению запроса, просто такая функция помощник
public string ExecuteRequest(Method method, string url)
{
    BuildRequest(method, url);
    return ExecuteRequest();
}

#endregion


Twitter's API implementation
Прежде, чем начать написание функционала, требуется представить сущности такие как статус, пользователь, таймлайны и другое, необходимое в вашем конкретном случае. Логично подумав было решено использовать Serializable классы для десериализации полученных XML от Twitter API. Собственно как это дело описано у меня смотрим ниже.

Как сериализировать и десериализировать объекты, приведу две функции:

/// <summary>
/// Deserialize Xml String to CSharp Object
/// </summary>
/// <typeparam name="T">Class of the serializable CSharp Object</typeparam>
/// <param name="Xml">Xml String</param>
/// <returns>Serialized CSharp Object</returns>
public static T XmlDeserialize<T>(string Xml) where T : class
{
    var xs = new XmlSerializer(typeof(T));
    var reader = new StringReader(Xml);
    return (T)xs.Deserialize(reader);
}

/// <summary>
/// Serialize Xml String to CSharp Object
/// </summary>
/// <typeparam name="T">Class of the serializable CSharp Object</typeparam>
/// <param name="Object">serializable CSharp Object</param>
/// <returns>XML String</returns>
public static string XmlSerialize<T>(T Object) where T : class
{
    var xs = new XmlSerializer(typeof(T));
    var writer = new StringWriter();
    xs.Serialize(writer, Object);
    return writer.ToString();
}

Дальше объявление сущностей

#region Declarations

[Serializable]
[XmlRoot("user")]
public class TwitterUser
{
    [XmlElement("id")]
    public long Id;
    
    [XmlElement("name")]
    public string Name;
    
    [XmlElement("profile_image_url")]
    public string ProfileImageUrl;
    
    [XmlElement("screen_name")]
    public string ScreenName;
    
    [XmlElement("url")]
    public string Url;
}

[Serializable]
[XmlRoot("status")]
public class TwitterStatus
{
    [XmlElement("id")]
    public long Id;
    
    [XmlElement("text")]
    public string Text;
    
    [XmlElement("source")]
    public string Source;
    
    [XmlElement("user")]
    public TwitterUser User;
    
    [XmlIgnore()]
    private string _CreatedAtOrg;
    
    [XmlElement("created_at")]
    public string CreatedAtOrg
    {
        get { return _CreatedAtOrg; }
        set
        {
            _CreatedAtOrg = value;
            CreatedAt = ParseDateTime(_CreatedAtOrg);
        }
    }
    
    [XmlIgnore()]
    public DateTime CreatedAt;
    
    private static DateTime ParseDateTime(string date)
    {
        // <created_at>Sun Apr 25 11:25:52 +0000 2010</created_at>
        try
        {
            var dateFormat = "ddd MMM dd HH:mm:ss zzzz yyyy";
            return DateTime.ParseExact(date, dateFormat, System.Globalization.DateTimeFormatInfo.InvariantInfo);
        }
        catch
        {
            return DateTime.Now;
        }
    }
}

[Serializable]
[XmlRoot("direct_message")]
public class DirectMessage
{
    [XmlElement("id")]
    public long Id;
    
    [XmlElement("sender_id")]
    public long SenderId;
    
    [XmlElement("text")]
    public string Text;
    
    [XmlElement("recipient_id")]
    public long RecipientId;
    
    [XmlElement("created_at")]
    public DateTime CreatedAt;
    
    [XmlElement("sender_screen_name")]
    public string SenderScreenName;
    
    [XmlElement("recipient_screen_name")]
    public string RecipientScreenName;
    
    [XmlElement("sender")]
    public TwitterUser Sender;
    
    [XmlElement("recipient")]
    public TwitterUser Recipient;
}

#endregion

#region URL Generator

private const string Host = "https://twitter.com/";
private const string ResponseFormat = ".xml";

private const string ApiAccountVerifyCredentials = "account/verify_credentials";
private const string ApiAccountEndSession = "account/end_session";

private const string ApiStatusesHomeTimeline = "statuses/home_timeline";
private const string ApiStatusesUpdate = "statuses/update";
private const string ApiStatusesDestroy = "statuses/destroy";

private const string ApiDirectMessageNew = "direct_messages/new";

private string Url(string Api)
{
    return string.Concat(Host, Api, ResponseFormat);
}

#endregion

#region Methods

private delegate TwitterUser VerifyCredentialsDelegate();
public TwitterUser VerifyCredentials()
{
    var response = ExecuteRequest(Method.GET, Url(ApiAccountVerifyCredentials));
    return Extensions.Helper.XmlDeserialize<TwitterUser>(response);
}

public delegate void TwitterVerifyCredentialsCallback(TwitterUser user, Exception e);
public void VerifyCredentialsAsync(TwitterVerifyCredentialsCallback callback)
{
    (new Thread(new ThreadStart(delegate()
        {
            try
            {
                var user = VerifyCredentials();
                if (callback != null)
                {
                    callback(user, null);
                }
            }
            catch (Exception e)
            {
                if (callback != null)
                {
                    callback(null, e);
                }
            }
        }))).Start();
}

public List<TwitterStatus> StatusesHomeTimeline(long? since_id, long? max_id, long? count, long? page)
{
    BuildRequest(Method.GET, Url(ApiStatusesHomeTimeline));
    if (since_id.HasValue)
    {
        Parameters.Add("since_id", since_id.Value.ToString());
    }
    if (max_id.HasValue)
    {
        Parameters.Add("max_id", max_id.Value.ToString());
    }
    if (count.HasValue)
    {
        Parameters.Add("count", count.ToString());
    }
    if (page.HasValue)
    {
        Parameters.Add("page", page.ToString());
    }
    var response = ExecuteRequest();
    var xmlDoc = new XmlDocument();
    xmlDoc.LoadXml(response);
    var statuses = new List<TwitterStatus>();
    var xmlstatuses = xmlDoc.DocumentElement.GetElementsByTagName("status");
    foreach (XmlElement child in xmlstatuses)
    {
        statuses.Add(Extensions.Helper.XmlDeserialize<TwitterStatus>(child.OuterXml));
    }
    return statuses;
}

public delegate void StatusesHomeTimelineCallback(List<TwitterStatus> statuses, Exception e);
public void StatusesHomeTimelineAsync(long? since_id, long? max_id, long? count, long? page, StatusesHomeTimelineCallback callback)
{
    (new Thread(new ThreadStart(delegate()
    {
        try
        {
            var statuses = StatusesHomeTimeline(since_id, max_id, count, page);
            if (callback != null)
            {
                callback(statuses, null);
            }
        }
        catch (Exception e)
        {
            if (callback != null)
            {
                callback(null, e);
            }
        }
    }))).Start();
}

public TwitterStatus StatusesUpdate(string status, long? in_reply_to_status_id)
{
    BuildRequest(Method.POST, Url(ApiStatusesUpdate));
    Parameters.Add("status", status);
    if (in_reply_to_status_id.HasValue)
    {
        Parameters.Add("in_reply_to_status_id", in_reply_to_status_id.Value.ToString());
    }
    var response = ExecuteRequest();
    return Extensions.Helper.XmlDeserialize<TwitterStatus>(response);
}

public delegate void StatusesUpdateCallback(TwitterStatus status, Exception e);
public void StatusesUpdateAsync(string status, long? in_reply_to_status_id, StatusesUpdateCallback callback)
{
    (new Thread(new ThreadStart(delegate()
    {
        try
        {
            var _status = StatusesUpdate(status, in_reply_to_status_id);
            if (callback != null)
            {
                callback(_status, null);
            }
        }
        catch (Exception e)
        {
            if (callback != null)
            {
                callback(null, e);
            }
        }
    }))).Start();
}

public TwitterStatus StatusesDestroy(long id)
{
    BuildRequest(Method.POST, Url(ApiStatusesDestroy));
    Parameters.Add("id", id.ToString());
    var response = ExecuteRequest();
    return Extensions.Helper.XmlDeserialize<TwitterStatus>(response);
}

public delegate void StatusesDestroyCallback(TwitterStatus status, Exception e);
public void StatusesDestroyAsync(long id, StatusesUpdateCallback callback)
{
    (new Thread(new ThreadStart(delegate()
    {
        try
        {
            var _status = StatusesDestroy(id);
            if (callback != null)
            {
                callback(_status, null);
            }
        }
        catch (Exception e)
        {
            if (callback != null)
            {
                callback(null, e);
            }
        }
    }))).Start();
}

public void AccountEndSession()
{
    ExecuteRequest(Method.POST, Url(ApiAccountEndSession));
}

public delegate void AccountEndSessionCallback(Exception e);
public void AccountEndSessionAsync(AccountEndSessionCallback callback)
{
    (new Thread(new ThreadStart(delegate()
    {
        try
        {
            AccountEndSession();
            if (callback != null)
            {
                callback(null);
            }
        }
        catch (Exception e)
        {
            if (callback != null)
            {
                callback(e);
            }
        }
    }))).Start();
}

public DirectMessage DirectMessageNew(long userId, string text)
{
    BuildRequest(Method.POST, Url(ApiDirectMessageNew));
    Parameters.Add("user", userId.ToString());
    Parameters.Add("text", text);
    var response = ExecuteRequest();
    return Extensions.Helper.XmlDeserialize<DirectMessage>(response);
}

public delegate void DirectMessageNewCallback(DirectMessage message, Exception e);
public void DirectMessageNewAsync(long userId, string text, DirectMessageNewCallback callback)
{
    (new Thread(new ThreadStart(delegate()
    {
        try
        {
            var message = DirectMessageNew(userId, text);
            if (callback != null)
            {
                callback(message, null);
            }
        }
        catch (Exception e)
        {
            if (callback != null)
            {
                callback(null, e);
            }
        }
    }))).Start();
}

public static void ParseSource(string source, ref string href, ref string text)
{
    try
    {
        XmlDocument xml = new XmlDocument();
        xml.LoadXml(source);
        href = xml["a"].Attributes["href"].Value;
        text = xml["a"].InnerText;
    }
    catch
    {
        text = source;
    }
}

public void RequestToken()
{
    var response = ExecuteRequest(Method.POST, "https://api.twitter.com/oauth/request_token");
    var keys = HttpUtility.ParseQueryString(response);
    foreach (var key in keys.AllKeys)
    {
        if (key == "oauth_token")
        {
            Token = keys[key];
        }
        if (key == "oauth_token_secret")
        {
            TokenSecret = keys[key];
        }
    }
}

public delegate void RequestTokenCallback(string Token, string TokenSecret, Exception e);
public void RequestTokenAsync(RequestTokenCallback callback)
{
    (new Thread(new ThreadStart(delegate()
    {
        try
        {
            RequestToken();
            if (callback != null)
            {
                callback(Token, TokenSecret, null);
            }
        }
        catch (Exception e)
        {
            if (callback != null)
            {
                callback(string.Empty, string.Empty, e);
            }
        }
    }))).Start();
}

public void AccessToken(string PIN)
{
    var response = ExecuteRequest(Method.POST, "https://api.twitter.com/oauth/access_token?oauth_verifier=" + PIN);
    var keys = HttpUtility.ParseQueryString(response);
    foreach (var key in keys.AllKeys)
    {
        if (key == "oauth_token")
        {
            Token = keys[key];
        }
        if (key == "oauth_token_secret")
        {
            TokenSecret = keys[key];
        }
    }
}

public delegate void AccessTokenCallback(string Token, string TokenSecret, Exception e);
public void AccessTokenAsync(string PIN, AccessTokenCallback callback)
{
    (new Thread(new ThreadStart(delegate()
    {
        try
        {
            AccessToken(PIN);
            if (callback != null)
            {
                callback(Token, TokenSecret, null);
            }
        }
        catch (Exception e)
        {
            if (callback != null)
            {
                callback(string.Empty, string.Empty, e);
            }
        }
    }))).Start();
}

#endregion

Все?
Да, все.
  • Какие методы и как вызывать? Постфикс Async говорит о том, что данный метод будет вызван в отдельном потоке и требует соответствующего callback'а.
  • В какой последовательности вызывать? Вначале поста привел картинку, где вы могли наблюдать схематически понятную цепь событий для авторизации пользователя и получении всех необходимых токенов.
Все рад видет новых читателей, спасибо и до встречи.

No comments:

Post a Comment