Evernote SDK for Golang 自行建置、使用範例以及開放授權流程 (OAuth)
由於個人需求需要能從 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,開發流程會是:
- 線上申請,取得 API Key
- 透過 OAuth 在開發環境 (Sandbox) 測試,注意:該 API Key 無法取得正式環境 (Production) 的認證 Token
- 送出 Activation request,啟用正式的 key activation
- 等候幾個工作天,待官方回覆
- 通過啟用,服務正式上線
經驗是申請後發現給的權限不是所需要的,特地還寫信詢問請求調整權限,來來回回花了兩三個禮拜搞定,在等待的過程就先使用 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)
}
}
其他注意事項
- NoteStoreClient 一次只能處理一個要求,多個 Go-routine 向 Client 要求資料會造成不可預期錯誤,要記得設定
sync.Lock
- 下載大資源 (Resource) 速度不快,建議使用 content hash 建立快取機制 (Caching),使用
GetResourceByHash
來取得資源內容
沒有留言: