關於 web service, unity, blogger 等軟體工程筆記

Evernote SDK for Golang 自行建置、使用範例以及開放授權流程 (OAuth)

Edit icon 沒有留言
Evernote SDK for Golang

由於個人需求需要能從 Evernote 下載筆記以及資源,但官方網站沒有能看到 SDK for Golang,且官方 Github 帳號中也沒有看到,想必目前是現在還沒有正式支援吧,也可能是 Golang 開發者還不常接 Evernote……。

但從 Github 上能找到第三方的 Evernote SDK,在這裡看到能使用 Evernote-Thrift 產生 SDK,所以就研究怎麼使用 Thrift 來產生 Evernote SDK for Golang,紀錄整個建置流程以及其 SDK 使用範例。

關於 Apache Thrift

Thrift 是一套由 Facebook 開發的工具(現在由 Apache 維護),能將描述伺服器服務接口的中介語言,透過程式碼產生器,產生指定程式語言的 Clent & Server 實作,使得服務能夠簡單容易的跨語言開發。

因此可以透過 Evernote-Thrift 產生 golang client (SDK) 的實作,甚至是其他語言,例如 C#, JavaScript, Java, or C++ 等等的 Client 實作,一套描述能產生多語言的 SDKs。

Evernote SDK for Golang 建置步驟

  • 下載 Thrift 最新版本 (使用 0.10.0 binary)
  • 安裝 Thrift go 套件
    go get git.apache.org/thrift.git/lib/go/thrift
    
  • 下載 Evernote-Thrift (使用 May 14, 2017 上傳的版本)
  • 執行 Thrift,產生 Golang 程式碼 針對每一個 .thrift (Errors.thrift, Limits.thrift, NoteStore.thrift, Types.thrift, and UserStore.thrift), 產生 .go 程式碼
    thrift -strict -nowarn --allow-64bit-consts --allow-neg-keys evernote-thrift/src/UserStore.thrift
    
  • 將 /gen-go/edam 資料夾搬移到合適的地方 (Go compiler 編譯連結找得到的地方)
    • 例如專案的 /vendor 抑或是 %GOPATH/src%
    • 文章撰寫時是放在 %GOPATH/src/evernote.com/edam%,因此之後範例是使用下列方式來引用
      import (
        "evernote.com/edam"
      )
      
  • 手動修正一些編譯錯誤 Thrift 產生的 go code,存在一些需手動修正的編譯錯誤,主要是型態錯誤需要加入轉型:
    .\Types.go:11082: cannot use _elem7 (type string) as type GUID in append
    
    發生編譯錯誤的程式碼:
    append(p.TagGuids, _elem7)
    
    修正後可編譯的程式碼:
    append(p.TagGuids, GUID(_elem7))
    

取得開發用 Token 以及環境選擇

使用 Evernote SDK 必須使用一組認證代碼 (Authorization token),來登入該帳號來使用該 SDK 查詢/ 修改/ 新增等筆記操作,可以採用 OAuth 讓使用者登入提供該 Token,抑或是使用開發者工具來取得。

Evernote 除了正式環境 (Production environment) 外,也有提供沙箱環境 (Sandbox environment, e.g. for testing environment) 可供測試,細節請參考 https://sandbox.evernote.com/。為了避免開發階段毀損你的筆記,官方建議先使用沙箱模式來開發測試,待一切完成後再切換回正式環境。

為了下節的範例所需的認證代碼,先決定使用哪個環境後(自己自己知道風險,很懶惰直接選擇正式環境開發),選擇下列連結從網頁,直接取得一年期的開發認證代碼 (Developer authorization token),注意擁有該 Token 便可登入進行該帳號筆記操作,請謹慎保管其 Tokn:

若沒有要在使用該 Developer Token,建議回到同一個網頁撤銷其 Token (Revoke),避免該 Token 被其他人使用 。

使用 SDK 範例

先準備以下函數,協助選擇執行環境(正式 or 開發環境)以及建立所需的 API 接口,採用 BinaryProtocol 來進行傳輸資料 (關於 Thrift 目前有支援的 protocol 可以參考 Wiki):

package main

import (
   "fmt"

   "evernote.com/edam" // Note: 修改 SDK 所在位置,此範例其 SDK 放置於 %GOPATH%/src/evernote.com/edam
   "git.apache.org/thrift.git/lib/go/thrift"
)

type EnvironmentType int

const (
   SANDBOX EnvironmentType = iota
   PRODUCTION
)

func Host(envType EnvironmentType) string {
   host := "www.evernote.com"
   if envType == SANDBOX {
      host = "sandbox.evernote.com"
   }
   return host
}

func NewUserStore(envType EnvironmentType) (*edam.UserStoreClient, error) {
   url := fmt.Sprintf("https://%s/edam/user", Host(envType))
   c, err := thrift.NewTHttpPostClient(url)
   if err != nil {
      return nil, err
   }
   return edam.NewUserStoreClientFactory(
      c,
      thrift.NewTBinaryProtocolFactoryDefault(),
   ), nil
}

func NewNoteStore(userstore *edam.UserStoreClient, authenticationToken string) (*edam.NoteStoreClient, error) {
   urls, err := userstore.GetUserUrls(authenticationToken)
   if err != nil {
      return nil, err
   }

   url := urls.GetNoteStoreUrl()
   c, err := thrift.NewTHttpPostClient(url)
   if err != nil {
      return nil, err
   }

   return edam.NewNoteStoreClientFactory(
      c,
      thrift.NewTBinaryProtocolFactoryDefault(),
   ), nil
}

因此可以準備開始初始化:

package main

const (
   EvernoteEnvironment EnvironmentType = PRODUCTION
   EvernoteAuthorToken string = "YOUR_TOKEN" // 使用上節取得的 Token
)

func main() {
   us, err := NewUserStore(EvernoteEnvironment)
   if err != nil {
      panic(err)
   }

   ns, err := NewNoteStore(us, EvernoteAuthorToken)
   if err != nil {
      panic(err)
   }

   // TODO: Use ns to do something...
}

取得預設的記事本:

notebook, err := ns.GetDefaultNotebook(EvernoteAuthorToken)
if err != nil {
   panic(err)
} else if notebook == nil {
   panic("Invalid Notebook")
}
println("Default notebook: ", notebook.GetName())

列出所有的記事本:

notebooks, err := ns.ListNotebooks(EvernoteAuthorToken)
if err != nil {
   panic(err)
} else if notebooks == nil {
   panic("Invalid Notebooks")
}
for idx, notebook := range notebooks {
   println("Notebook[", idx, "]:", notebook.GetName())
}

取得指定記事本的筆記,列出第 0~50 筆,使用 NoteFilter 來向 Evernote server 查詢,並透過 NotesMetadataResultSpec 設定回傳的資料有哪些 (該範例僅要求筆記的標題):

maxNotes := int32(50)

notebook, _ := ns.GetDefaultNotebook(EvernoteAuthorToken)
filter := edam.NewNoteFilter()
filter.NotebookGuid = notebook.GUID   // TODO: interest note's guid

it := true
resultSpec := edam.NewNotesMetadataResultSpec()
resultSpec.IncludeTitle = &it

notes, err := ns.FindNotesMetadata(EvernoteAuthorToken, filter, 0, maxNotes, resultSpec)
if err != nil {
   panic(err)
}

for idx, note := range notes.GetNotes() {
   println("Note[", idx, "]: ", note.GetTitle(), ", guid:", note.GetGUID())
}

取得指定筆記中的內容以及資源範例,資源可以是圖片、聲音、或是任何 Evernote 支援能插入在筆記的檔案,使用 NoteResultSpec 設定回傳的資料,在這個範例中僅要求筆記的內容,沒有包含資源的內容 (resource body):

// import("encoding/hex")
it := true
resultSpec := edam.NewNoteResultSpec()
resultSpec.IncludeContent = &it

noteGUID := edam.GUID("6db70c8a-edcb-4d82-afa7-84a81834f411")
note, err := ns.GetNoteWithResultSpec(EvernoteAuthorToken, noteGUID, resultSpec)
if err != nil {
   panic(err)
}

println("Content:", note.GetContent())

for idx, res := range note.GetResources() {
   println("Note Resources[", idx, "]:", res.GetMime(), ", GUID:", res.GetGUID(), ", hash:", hex.EncodeToString(res.GetData().GetBodyHash()), ", size:", res.GetData().GetSize())
}

一個筆記內容的回傳範例:

<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">

<en-note><div><en-media hash="d710aaf3af682bc4579be06e1054f62d" style="height:auto;" type="image/png" width="250"/></div><div>這是內文</div><div>七月份地點在於...</div><div><br/></div><div><en-media hash="89f03b86bb854645a8e90f11b2122d9c" type="image/png"/></div><div><br/></div></en-note>

以及其資源列表:

Note Resources[ 0 ]: image/png , GUID: 89dda441-9aea-48fa-a83e-f19de7d47f3c , hash: 89f03b86bb854645a8e90f11b2122d9c , size: 25479
Note Resources[ 1 ]: image/png , GUID: f4bbb01e-f9fe-4496-bb6b-4e429637c30b , hash: d710aaf3af682bc4579be06e1054f62d , size: 551806

之後可以使用 GetResource 來取得資源 content-body:

resGUID := edam.GUID("f4bbb01e-f9fe-4496-bb6b-4e429637c30b")
res, err := ns.GetResourceData(EvernoteAuthorToken, resGUID)
if err != nil {
   panic(err)
}

b := res.GetData().GetBody()
println(b)
// TODO: b is content-body

或是使用 GetResourceByHash 來取得:

noteGUID := edam.GUID("6db70c8a-edcb-4d82-afa7-84a81834f411")
resHash, _ := hex.DecodeString("d710aaf3af682bc4579be06e1054f62d")

res, err := ns.GetResourceByHash(EvernoteAuthorToken, noteGUID, resHash, true, false, false)
if err != nil {
    panic(err)
}

b := res.GetData().GetBody()
println(b)
// TODO: b is content-body

更多 API 說明以及使用方式可以到官方文件查詢。

建立 OAuth 取得 Token

Evernote 採用 OAuth (注意不是 OAuth2) 來取得認證 Token,開發流程會是:

經驗是申請後發現給的權限不是所需要的,特地還寫信詢問請求調整權限,來來回回花了兩三個禮拜搞定,在等待的過程就先使用 Sandbox 環境測試登入,以及開發 Token 登入正式環境取得資料測試……。

以下是使用 github.com/mrjones/oauth 進行登入正式環境,取得驗證 Token 範例,建立兩個 Handler /evernote/login 以及 /evernote/callback。/evernote/login 負責進行授權資料的初始化,並將用戶瀏覽器導到 Evernote 的登入頁面。待用戶完成授權後,將會導回到 /evernote/callback,從參數再向 Evernote 驗證取得正式可用的授權 Token。

package main

import (
   "encoding/gob"
   "fmt"
   gcontext "github.com/gorilla/context"
   "github.com/gorilla/sessions"
   "github.com/mrjones/oauth"
   "net/http"
)

const (
   EvernoteKey    = "YOUR_KEY"
   EvernoteSecret = "YOUR_SECRET"

   sessionName = "TWSIYAN-LOGIN-EXAMPLE"
)

var store = sessions.NewCookieStore([]byte("something-very-secret"))

func main() {
   host := Host(PRODUCTION)
   client := oauth.NewConsumer(
      EvernoteKey, EvernoteSecret,
      oauth.ServiceProvider{
         RequestTokenUrl:   fmt.Sprintf("https://%s/oauth", host),
         AuthorizeTokenUrl: fmt.Sprintf("https://%s/OAuth.action", host),
         AccessTokenUrl:    fmt.Sprintf("https://%s/oauth", host),
      },
   )

   println("Go to the url below to login: http://127.0.0.1:8080/evernote/login")

   gob.Register(&oauth.RequestToken{})

   m := http.NewServeMux()
   s := &http.Server{
      Addr:    ":8080",
      Handler: gcontext.ClearHandler(m),
   }

   m.HandleFunc("/evernote/login", func(w http.ResponseWriter, req *http.Request) {
      session, err := store.Get(req, sessionName)
      if err != nil {
         http.Error(w, err.Error(), http.StatusInternalServerError)
         return
      }

      requestToken, url, err := client.GetRequestTokenAndUrl("http://127.0.0.1:8080/evernote/callback")
      if err != nil {
         http.Error(w, err.Error(), http.StatusInternalServerError)
         return
      }

      session.Values["requestToken"] = requestToken
      session.Save(req, w)

      http.Redirect(w, req, url, http.StatusTemporaryRedirect)
   })

   m.HandleFunc("/evernote/callback", func(w http.ResponseWriter, req *http.Request) {
      session, err := store.Get(req, sessionName)
      if err != nil {
         http.Error(w, err.Error(), http.StatusInternalServerError)
         return
      }

      requestToken, ok := session.Values["requestToken"].(*oauth.RequestToken)
      if !ok {
         http.Error(w, "Bad request, NO SESSION", http.StatusBadRequest)
         return
      }

      values := req.URL.Query()
      if token, ok := values["oauth_token"]; !ok || token[0] != requestToken.Token {
         http.Error(w, "Bad request, NO OAUTH_TOKEN", http.StatusBadRequest)
         return
      }

      verifier, ok := values["oauth_verifier"]
      if !ok {
         http.Error(w, "User decline", http.StatusOK)
         return
      }

      authorizedToken, err := client.AuthorizeToken(requestToken, verifier[0])
      if err != nil {
         http.Error(w, err.Error(), http.StatusInternalServerError)
         return
      }

      w.WriteHeader(http.StatusOK)
      fmt.Fprintln(w, "OK, token", authorizedToken.Token)
   })

   if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
      panic(err)
   }
}
Evernote 授權畫面

Evernote 授權畫面

其他注意事項

  • NoteStoreClient 一次只能處理一個要求,多個 Go-routine 向 Client 要求資料會造成不可預期錯誤,要記得設定 sync.Lock
  • 下載大資源 (Resource) 速度不快,建議使用 content hash 建立快取機制 (Caching),使用 GetResourceByHash 來取得資源內容

Reference

沒有留言: