watermint.org - Takayuki Okazaki's note

Dropbox API: チーム全体のフォルダ・ファイル一覧を取得する

Dropbox BusinessではAPIを通じてチーム内のユーザーについてファイル・フォルダ一覧を取得したり、更新することができます。これは、マルウェア対策システムがファイルを検査・隔離するために利用したり、ワークフローや生産性向上ツールが自動的にファイルを仕分けするといった用途を想定していると考えられます。なお便利な一方、非常に強力な権限となるため取り扱い上の注意は管理者パスワードなどと同様厳重に管理する必要があることは言うまでもありません。

このようにチーム全体のファイルを扱う場合にはDropbox APIの概要のBusiness Endpointsでご紹介したTeam member file access権限を用います。この権限を使うアプリケーションを作成する手順などは該当記事をご参照ください。

チームメンバーのファイルやフォルダを操作するには大きく分けて二種類の方法があります。

  1. チームメンバーの代理として実行
  2. 管理者として実行

チームメンバーの代理として実行する方法では、指定したユーザーが所有しているファイルやフォルダ、参加している共有フォルダ・チームフォルダなどへアクセスすることができます。 管理者として実行する場合にはチーム内すべてのファイル・フォルダへアクセスすることができます。

使い分けとして、たとえばワークフローアプリケーションなどで承認されたドキュメントを所定フォルダに移動するといった用途であれば、承認者の権限で承認者が所有フォルダに移動するといった場合には代理での実行が適切でしょう。マルウェアのスキャンや監査といったチーム全体を対象としたい場合には管理者としての実行が適切でしょう。

チームメンバーの代理として実行

チームメンバーの代理として実行する場合にはまずteams/members/listでアカウント一覧を取得します。User Endpointに対してAPIコールする際にHTTPヘッダDropbox-API-Select-User: チームメンバーIDを追加すると、その該当アカウントとして処理が行われます。

たとえばfiles/list_folderを代理として実行する場合には次のように呼び出します。

curl -X POST https://api.dropboxapi.com/2/files/list_folder \
  --header 'Authorization: Bearer 認証トークン' \
  --header 'Content-Type: application/json' \
  --header 'Dropbox-Api-Select-User: チームメンバーID' \
  --data '{"path":""}'

チーム管理者として実行

チーム管理者として実行する場合にはDropbox-API-Select-Userヘッダの代わりにDropbox-API-Select-Adminヘッダを用います。また、チームメンバーIDにはチーム管理者のメンバーIDを指定します。 Dropbox-API-Select-Adminヘッダを用いた場合、チーム内のネームスペースすべてにアクセス可能となりますがAPIによって二つのモードがあり可視範囲が変わります。

一つ目はWhole Teamと呼ばれるモードです。主に参照系APIが該当します。該当APIはAPIドキュメントにてAUTHENTICATIONにDropbox-API-Select-Admin (Whole Team)と記載されています。Whole Teamモードではメンバーのプライベートファイルを含むすべてのファイルにアクセスすることができます。

もう一つはTeam Adminと呼ばれるモードです。主に更新系APIが該当します。該当APIはAPIドキュメントにてAUTHENTICATIONにDropbox-API-Select-Admin (Team Admin)と記載されています。Team Adminモードではメンバーのプライベートファイルにはアクセスできません。

チーム管理者として実行する際の流れは大まかに次のようになると思います。

  1. 実行するチーム管理者のメンバーIDを取得
  2. チームフォルダ一覧やネームスペース一覧を取得
  3. 各ネームスペースについて処理

まずチームメンバーの代理として実行する場合と同様にteams/members/listでアカウント一覧を取得しチーム管理者のメンバーIDを取得します。アプリケーション用に専用のアカウントが用意できるようであればアプリケーションにあらかじめメンバーIDを設定しておくといった運用も可能でしょう。

次にチームフォルダ一覧やネームスペース一覧を取得します。チームフォルダ一覧はteam/team_folder/list、ネームスペース一覧はteam/namespaces/listで取得できます。ワークフロー処理などチームフォルダ内で処理が完結する場合はteam/team_folder/list、チーム全体のファイルを監査するといった処理の場合はネームスペース一覧から処理を始めるとよいでしょう。

処理したいチームフォルダやネームスペースのネームスペースIDを取得します。なお、チームフォルダIDはそのままネームスペースIDとして利用できます。たとえば、team/team_folder/listのレスポンスが次のような場合、ネームスペースIDは123456789となります。

{
    "team_folders": [
        {
            "team_folder_id": "123456789",
            "name": "Marketing",
            "status": {
                ".tag": "active"
            },
            "is_team_shared_dropbox": false
        }
    ],
    "cursor": "ZtkX9_EHj3x7PMkVuFIhwKYXEpwpLwyxp9vMKomUhllil9q7eWiAu",
    "has_more": false
}

ネームスペースを使ってファイル一覧を取得する

パスとネームスペースの扱いについては以前の記事Dropbox API: ネームスペースとパスの表現にてご紹介いたしました。こちらでご紹介したとおり、ns:ネームスペースID/パスといった形式でパス指定すれば通常のファイル一覧取得と同じようにファイル一覧を取得することができます。

たとえばチームフォルダ(ネームスペースID = 123456789)のトップフォルダからファイル一覧を取得するには次のように実行します。

curl -X POST https://api.dropboxapi.com/2/files/list_folder \
  --header 'Authorization: Bearer 認証トークン' \
  --header 'Content-Type: application/json' \
  --header 'Dropbox-Api-Select-Admin: チーム管理者のメンバーID' \
  --data '{"path":"ns:123456789"}'

mruby and go: value mapping library

I’m recently working on my hobby project that requires DSL on Go language. From my little research of DSL on Go; go-mruby looks attractive to implement DSL. mruby is the lightweight version of Ruby. That is embeddable into an app. With go language, an app can be compilable into a single binary.

go-mruby

go-mruby have set of functions that enable essential integration with mruby code and Go. For example, go-mruby can define mruby class or method from Go. But current functions are it’s too primitive for dealing with real-world applications.

Like mapping values from mruby to Go, or vice versa. It’s a bit tiresome for getting/putting each struct field. For example below, just for two fields of struct require 9 lines of code.

mrb := mruby.NewMrb()
defer mrb.Close()

type Planet struct {
    Id   int
    Name string
}

rv, err := mrb.LoadString(`
class Planet
  attr_accessor :id
  attr_accessor :name
end

p = Planet.new
p.id   = 3
p.name = "Earth"
p
`)
if err != nil {
    panic(err.Error())
}

p := Planet{}
if rId, err := rv.Call("id"); err != nil {
    panic(err.Error())
} else {
    p.Id = rId.Fixnum()
}
if rName, err := rv.Call("name"); err != nil {
    panic(err.Error())
} else {
    p.Name = rName.String()
}
fmt.Printf("Planet: %v\n", p)

For more productivity, I wrote mapping functions. Functions are open sourced on GitHub in MIT license. grb.

grb

Unmarshal Ruby values into Go struct data

Func Unmarshal fetch values for every field in Ruby object that defined by mruby:"METHOD_NAME" tag.

type Planet struct {
    Id         int            `mruby:"id"`
    Name       string         `mruby:"name"`
    Radius     float64        `mruby:"radius"`
    HasMoon    bool           `mruby:"has_moon"`
    Moon       []string       `mruby:"moon"`
    MoonRadius map[string]int `mruby:"moon_radius"`
}

Define each value accessor in Ruby class.

class Planet
  attr_accessor :id
  attr_accessor :name
  attr_accessor :radius
  attr_accessor :has_moon
  attr_accessor :moon
  attr_accessor :moon_radius
end

Retrieve ruby value *ruby.MrbValue, then unmarshal it into the Go struct.

rv, _ := mrb.LoadString(`
p = Planet.new
p.id          = 3
p.name        = "Earth"
p.radius      = 6371.0
p.has_moon    = true
p.moon        = ["Moon"]
p.moon_radius = {"Moon" => 1737}
p
`)

p := Planet{}
Unmarshal(rv, &p)

fmt.Printf("Planet: %v\n", p)
// Planet: {3 Earth 6371 true [Moon] map[Moon:1737]}

Decode/encode between Ruby values and Go values

Similar to func Unmarshal. Func DecodeMrbValue and EncodeMrbValue enable mapping values between mruby and Go. DecodeMrbValue/EncodeMrbValue have limitation for a type of value. This supports JSON equivalent types like below.

  • nil
  • bool
  • int
  • float
  • string
  • Array / slice
  • Hash / map (Hash/map key must be a string)
mrb := mruby.NewMrb()
defer mrb.Close()

// Mapping to mruby value
rv, err := EncodeMrbValue(mrb, map[string]interface{}{"Earth": 6371, "Moon": 1737})

// Mapping from mruby value
gv, err := DecodeMrbValue(rv)

GolangでBOM付きCSVファイルを読み込む

先行事例・解決策は山ほどあるかと思いますが、はまったのでメモがてら解決方法を。BOM付きUTF16などでエンコードされたテキストを扱うに当たって、ライブラリなどが対応してくれているといいのですが、GolangのCSVReaderは対応していないようです。このため、次のような関数を準備してエンコーディングを変換してやります。

func NewBomAwareCsvReader(r io.Reader) *csv.Reader {
  var (
    bomUtf8    = []byte{0xef, 0xbb, 0xbf}
    bomUtf16BE = []byte{0xfe, 0xff}
    bomUtf16LE = []byte{0xff, 0xfe}
  )
  br := bufio.NewReader(r)
  mark, err := br.Peek(3)
  if err != nil {
    panic(err)
  }

  // decoder
  d := unicode.UTF8.NewDecoder()

  if bytes.HasPrefix(mark, bomUtf8) {
    br.Discard(len(bomUtf8))
  } else if bytes.HasPrefix(mark, bomUtf16BE) {
    d = unicode.UTF16(unicode.BigEndian, 
      unicode.UseBOM).NewDecoder()
  } else if bytes.HasPrefix(mark, bomUtf16LE) {
    d = unicode.UTF16(unicode.LittleEndian,
     unicode.UseBOM).NewDecoder()
  }

  return csv.NewReader(transform.NewReader(br, d))
}

あとはファイルを読み込めば変換できます。ひとまず必要が無かったので対応しませんでしたが、utf32パッケージを使って同様処理を追加してやればUTF32も対応できます。

Googleマップのスターが消える問題と、Day One 2に移行した話

Google Mapsのスターとは、お気に入りのレストランなどにスターをつけてメモしておけるというものです。旅行先で行こうと考えている観光スポット、知人から教わったおすすめレストランなど今まで記録はすべてGoogle Mapsでスターをつけることでメモしていました。 ただこの便利な機能にも不満が2点。一つはスターが勝手に消えてしまうことで。二つ目があまり追加情報が書き込めないことです。

Google Mapsでのスター

今回はGoogle Mapsでスターをつけた位置をDay One 2の記事として移行した際のお話です。なお各種仕様は変わるかもしれませんので試される際には自己責任で。

Google Mapsでの2つの課題

課題1: スターが消える問題

まず一つ目のスターが勝手に消えてしまう問題について。少し検索してみると下記のように詳細に調査されている記事が見つかりました。

いろいろ丁寧に検証されているので大変参考になりました。結局、保存はされているが、どうやら表示数に制限があり、古いものから表示されなくなるのではとのこと。データが保存されていても簡単に取り出せないとするとあまり保存している意味もありません。

課題2: 追加情報が書き込めない

この中華料理屋さんの担々麺がおいしいとか、この焼き鳥屋さんは塩よりタレがおすすめとか些細ではあるものの、教えてもらった情報は書き留めておきたいものです。Google Mapsでは最近スターの種類が増えたのでカテゴリごとにスターを分けたりできますが、それでも追加情報を書き込むという段階まではたどり着きません。

Google+などSNSを使って管理する方法もあるのですが、各所に情報が分散しても使いづらいですし、自分で投稿したものにもかかわらず検索が難しいので数年前からこの目的のためにSNSは利用していません。

Day One 2へ移行

Day One 2はもともと日記などライフログをつけるためのアプリです。写真やメモに対して位置情報やタグなどを追加して情報を管理することができます。

Day Oneは2013年頃から使い始めていて、ようやく最近Day One 2へ移行を完了しました。Day One 2では基本的な使い勝手はそのままにかなり機能が追加されています。よく見ると、Day One 2ではJSON Zip Fileという形式でデータをエクスポートしたり、インポートすることができます。これを使えば、Google Mapsから一括してデータを読み込みできそうです。

Day One 2のJSON Zip File形式

JSON Zip File形式のドキュメントは見つかりませんでしたが、データ形式としてはごく単純なものです。ZIPファイルの中にJournalごとのJSONファイルと、写真データが格納されています。Day One 2にデータをインポートするにはこのJSONファイルをインポートすれば良さそうです。

JSONファイルは次のようなフォーマットです。Day One 2 version 2.5.10にて幾つかテストをしてみたところ、インポートの際には記事ごとのuuidは振らなくても自動的に発番されるので問題ありません。locationデータについても緯度(longitude)・経度(latitude)のみあればよく他は省略してもインポートは成功します。

{
"metadata" : {
  "version" : "1.0"
},
"entries" : [
{
  "starred" : false,
  "location" : {
    "region" : {
      "center" : {
        "longitude" : 120.1973037719727,
        "latitude" : 22.99810981750488
      },
      "radius" : 75
    },
    "longitude" : 120.1973,
    "placeName" : "有方公寓 Your Fun Apartment",
    "latitude" : 22.99811
  },
  "creationDate" : "2015-03-14T11:35:05Z",
  "text" : "[有方公寓 Your Fun Apartment](http:\/\/maps.google.com\/?cid=1337703935590192274)\n\nNo. 9, Lane 269, Section 2, Hai'an Road, West Central District, Tainan City, Taiwan 700",
  "timeZone" : "Asia\/Tokyo",
  "uuid" : "345585B421AF47DEA98B497C66CFBF8F",
  "duration" : 0
}
]}

Google Takeoutでスター一覧を取得

Google TakeoutでスターをつけたデータをGeoJSONとして取り出します。手順については先ほど掲載させていただいたGoogleマップの「スター」(お気に入りの場所)が、古いものから消えて表示されなくなる問題に詳しく書かれています。

GeoJSONの中身をみてみると以下のように緯度、経度、タイトル、Google Maps上でのURLなどが得られることがわかりました。

{
  "type" : "FeatureCollection",
  "features" : [ {
    "geometry" : {
      "coordinates" : [ 120.1972925, 22.9981111 ],
      "type" : "Point"
    },
    "properties" : {
      "Google Maps URL" : "http://maps.google.com/?cid=1337703935590192274",
      "Location" : {
        "Address" : "No. 9, Lane 269, Section 2, Hai'an Road, West Central District, Tainan City, Taiwan 700",
        "Business Name" : "有方公寓 Your Fun Apartment",
        "Country Code" : "TW",
        "Geo Coordinates" : {
          "Latitude" : "22.9981111",
          "Longitude" : "120.1972925"
        }
      },
      "Published" : "2015-03-14T11:35:05Z",
      "Title" : "有方公寓 Your Fun Apartment",
      "Updated" : "2017-02-22T12:03:22Z"
    },
    "type" : "Feature"
  },
]}

Day One 2のJSON Zip File形式に変換

あとは形式を変更するだけですが、JSON同士の変換となるので簡単です。今回はRubyで書きました。ライセンスはMITライセンスとしておきます。

# MIT License
# 
# Copyright (c) 2018 Takayuki Okazaki
# 
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
# 
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

require 'json'

def feature2entry(feature)
  def geo(f, l)
    if f.key?('Geo Coordinates')
      f['Geo Coordinates'][l]
    elsif f.key?(l)
      f[l]
    else
      p feature
      exit
    end
  end
  if feature['Location'].key?('Address')
    address = "\n\n#{feature['Location']['Address']}"
  else
    address = ''
  end
  if feature['Location'].key?('Business Name')
    place_name = feature['Location']['Business Name']
  else
    place_name = feature['Title']
  end

  {
    :location => {
      :placeName => place_name,
      :longitude => geo(feature['Location'], 'Longitude').to_f,
      :latitude  => geo(feature['Location'], 'Latitude').to_f,
    },
    :text => "[#{feature['Title']}](#{feature['Google Maps URL']})#{address}",
    :creationDate => feature['Published'],
  }
end

def dayone(entries)
  journal = {
    :metadata => {
      :version => "1.0"
    },
    :entries => entries
  }
  JSON.generate(journal)
end

geojson = JSON.load(open('Saved Places.json'))
entries = Hash[geojson['features'].map { |f| [f['properties']['Google Maps URL'], f] }]
journal = dayone(entries.values.map { |f| feature2entry(f['properties']) })

puts journal

あとはこの出力を Location.json など新しいジャーナル名として保存し、zipファイルにしてインポートします。これで無事にDay One 2に読み込み完了です。あとは、ゴミデータを削除したり、中華料理とか和食とかタグをつけて整理すれば完了です。

記事として読み込まれた位置情報

地図表示に切り替えればGoogle Mapsと同じように周辺の地図といままでメモした場所を一覧して確認することができます。

地図表示

Dropbox API: ネームスペースとパスの表現

Dropbox APIファイル操作をするときに必ず必要になってくるのがネームスペースの概念とファイルやフォルダへのパスです。実際にはほとんど意識することなく使えるのですが、知っていると見えてくる仕様もあるのでそのあたりを詳しく見てみましょう。

Dropboxがどのようにファイルを管理しているか

Dropboxがどのようにファイルを管理しているかみていきます。まずは次の記事を参照してみます。

これはStreaming同期という機能を紹介したブログエントリですがDropboxファイルシステムについても言及しています。Dropboxでは、ファイルシステムを抽象化するにあたってネームスペースという概念を定義しています。ネームスペースはすべてのユーザーのルートフォルダにあたり、また共有フォルダのように他のユーザーと共有しているスペースについては別のネームスペースが与えられます。

ユーザーがいくつかの共有フォルダを持っている場合、それぞれの共有フォルダのネームスペースは特定のパス以下にマウントされている状態となります。

パスの書式

APIドキュメントのPath formatsというセクションに詳しく書かれていますがいくつか特徴をピックアップしてみます。

  • ”” (空文字列)はルートフォルダを示す (ルートフォルダは “/” ではない)
  • すべてのファイルやフォルダはIDを持っていて、このIDでも表現できる (例: "id:abc123xyz")
  • IDで表現したフォルダからさらに相対パスを指定できる (例: "id:abc123xyz/hello.txt")
  • ネームスペースIDを指定して、相対パスを指定できる (例: "ns:12345/cupcake.png")
  • アルファベット大文字小文字の違いは無視されて、/A/B/c.txtも/a/b/c.txtも同一視される
  • パスの区切り文字は “/”. Windowsで利用されている “" ではありませんのでご注意

なお、ネームスペースIDやフォルダのIDを指定した場合、当然ながら該当フォルダなどへのアクセス権限が必要となります。

実行例

下図はルートフォルダからのファイル一覧を取得する例です。ルートフォルダから一覧を取得するためにpathには”“を指定しています。

curl -X POST https://api.dropboxapi.com/2/files/list_folder \
    --header "Authorization: Bearer <access token>" \
    --header "Content-Type: application/json" \
    --data "{\"path\": \"\"}"

ルートフォルダを示すためにもし、”/” のようにスラッシュを入れてしまうと次のようなエラーが返されます。

Error in call to API function “files/list_folder”: request body: path: Specify the root folder as an empty string rather than as “/”.

ネームスペース

ネームスペースについてもう少し詳しく見てみましょう。Namespace Guideにいくつか例がありますが、契約しているプランなどによって共有フォルダなどの種類に若干違いがあります。

ネームスペースとなるものとしては下記5つが列挙されています。いずれの場合も、ネームスペース = アクセス制御の範囲と考えると良いでしょう。

  • ユーザーのホームフォルダ
  • 共有フォルダ
  • チームフォルダ
  • チームルートフォルダ
  • アプリケーションフォルダ

ユーザーのホームフォルダはユーザー毎に作成されるものです。共有フォルダは共有フォルダとして新しくフォルダを共有した段階でそのフォルダが独立した別のネームスペースとして管理されます。

チームフォルダにはいくつか種類があるのですがどの場合でも独立したネームスペースとして管理されます。チームフォルダではチームフォルダ以下に作成したフォルダに追加のメンバーを設定することができるなど、共有フォルダにはできない設定が可能です。この場合、追加のメンバーを設定した下位のフォルダも実装上は別のネームスペースとして管理されることになります。

アプリケーションフォルダはアクセス範囲を制限した、Dropbox APIを利用するアプリケーション向けのフォルダです。各ユーザーのルートフォルダ以下「/アプリ/<アプリ名>」のようなフォルダにアプリケーション専用のデータを格納することができます。アプリケーションからみるとこのフォルダがルートフォルダとなり、ユーザーのホームフォルダなど他のネームスペースとなる部分にはアクセスできません。