Работаем с zenmoney API

39 minute read

Возможно вы знаете, что я обожаю сервис для учета финансов zenmoney.ru. Интерфейс и юзабельность, как по мне, просто на высоте. А самое главное - это их REST API, которое позволяет писать все возможные клиенты и делать экспорт ваших транзакций.

К сожалению, документации по API не так уж много и иногда попадаются достаточно противные ошибки. Поэтому я постараюсь провести вас через эти дебри и покажу как написать простой консольный клиент для работы с транзакциями.

Получаем доступы к API

У zenmoney есть отдельная страничка для разработчиков, на которой собрана вся информация по работе с API.

Прежде всего нам нужно добавить новое приложение. Для этого воспользуйтесь формой, которая появляется по клику на слове "скриптом" в разделе "Параметры OAuth-провайдера" или пройдите по этой ссылке.

Для регистрации приложения необходимо заполнить простую форму:

После отправки формы, в этом же окне(или на странице) вы увидите данные для вашего приложения, что-то вот такое:

Приложение зарегистрировано

Пожалуйста, сохраните данные параметры, вам необходимо будет указать в настройках библиотеки OAuth вашего приложения: Consumer Key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx Consumer Secret: xxxxxxxxxx Request token endpoint: http://api.zenmoney.ru/oauth/request_token Access token endpoint: http://api.zenmoney.ru/oauth/access_token Authorize URL: http://api.zenmoney.ru/access/ Authorize URL для мобильных устройств: http://api.zenmoney.ru/access/?mobile

Обязательно сохраните эти данные. Я так и не нашел способа как-то просматривать параметры уже зарегистрированных приложений или менять эти параметры. Поэтому надежно сохраните всю информацию.

После регистрации нашего приложения, можем начинать писать свой консольный клиент.

Авторизация

Как вы могли заметить, авторизация для API в zenmoney работает по OAuth протоколу. Cтарый добрый первый OAuth... Будем использовать пакет для безболезненной работы с этим проколом. Нас полностью устроит github.com/mrjones/oauth

Небольшое отступление. Чтобы не мучатся с флагами и ифами, будем использовать пакет github.com/codegangsta/cli, который, как по мне, значительно упрощает написание консольных утилит.

И так, первым делом готовим настройки для нашей авторизации. В файле 4gophers.rum/zenmoney/main.go:

package main

import (
    "github.com/codegangsta/cli"
    "github.com/mrjones/oauth"
)

func main() {
    consumer := oauth.NewConsumer(
        "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        "xxxxxxxxxx",
        oauth.ServiceProvider{
            RequestTokenUrl:   "http://api.zenmoney.ru/oauth/request_token",
            AuthorizeTokenUrl: "http://api.zenmoney.ru/access",
            AccessTokenUrl:    "http://api.zenmoney.ru/oauth/access_token",
        })

    consumer.Debug(false)

    app := cli.NewApp()
    app.Name = "zen"
    app.Usage = "auth, account"
    app.Commands = []cli.Command{
        {
            Name:  "auth",
            Usage: "get tokens",
            Action: func(c *cli.Context) {
                // ...
            },
        },
    }

    app.Run(os.Args)

Прежде всего, мы создаем consumer(новый экземпляр *oauth.Consumer), указывая те самые настройки, которые вы получили после регистрации приложения. Первым мы указываем Consumer Key, затем Consumer Secret. Последний параметр - экземпляр oauth.ServiceProvider с предустановленными настройками, необходимыми для успешной авторизации.

Давайте добавим отдельный пакет, назовем его 4gophers.ru/zenmoney/api. В этом пакете будем реализовывать все наши функции для работы с API.

Начнем с функции api.Auth(). Эта функция должна принимать указатель на oauth.Consumer, а результатом работы этой функции должны быть токен и секретный ключ.

В файле 4gophers.rum/zenmoney/api/zenmoney.go добавим код:

package api

import (
    "fmt"
    "log"

    "github.com/mrjones/oauth"
)

func Auth(c *oauth.Consumer) {

    requestToken, url, err := c.GetRequestTokenAndUrl("oob")
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("(1) Go to: " + url)
    fmt.Println("(2) Grant access, you should get back a verification code.")
    fmt.Println("(3) Enter that verification code here: ")

    var verificationCode string
    fmt.Scanln(&verificationCode)

    accessToken, err := c.AuthorizeToken(requestToken, verificationCode)
    if err != nil {
        log.Fatal(err)
    }

    fmt.Printf("token: %s secret: %s", accessToken.Token, accessToken.Secret)
}

Вызов c.GetRequestTokenAndUrl("oob") отдает нам requestToken и url. Строка "oob", которую мы передали вместо url для коллбека, означает "out-of-band" - это значит, что нам не нужно вызов нашего коллбека(у нас его просто нет)и пользователь сам сам будет решать как поступить с полученными токенами.

Далее мы выводим в консоль url, по которому должен пройти пользователь и ждем пока он введет проверочный код.

Если вы перейдете по указанному url в браузере, то после логина вас перекинет на указанный в настройках(при регистрации приложения) коллбек url. К сожалению, насколько я вник в тему, zenmoney не может работать совсем без редиректа. Ну что ж, это не страшно. В крайнем случае, мы можем сделать страничку, которая будет красиво отображать проверочный код, который приходит нам от сервера zenmoney. А пока, можно просто скопировать значение параметра oauth_verifier из url и вставить его в консоль.

Последнее, что мы должны сделать, это получить токены для доступа:

accessToken, err := c.AuthorizeToken(requestToken, verificationCode)

Теперь у нас есть честно добытые access.Token и access.Secret. Их нужно сохранить. Конечно, хранить токены в открытом виде это не безопасно, но для примера приложения вполне оправдано.

Для простого хранения токенов, будем использовать JSON. Для этого нам нужен пакет encoding/json.

package api

import (
    "encoding/json"
    "os"
    // ...

    "github.com/kardianos/osext"
    // ...
)

func Auth(c *oauth.Consumer) {
    // ...

    folder, err := osext.ExecutableFolder()
    if err != nil {
        log.Fatal(err)
    }

    file, err := os.Create(folder + "/keys.json")
    if err != nil {
        log.Fatal(err)
    }

    json.NewEncoder(file).Encode(access)
}

Возможно вас смутит пакет github.com/kardianos/osext и вызов osext.ExecutableFolder(), но эта большая хитрость необходима, чтобы файл keys.json всегда создавался рядом с бинарником программы и не зависел от папки, откуда вы его запускаете. Это сослужит вам хорошую службу, когда вы будете устанавливать приложение глобально, с помощью go get.

Не забудьте вызвать Auth() в main.go:

package main

import (
    // ...
    "4gophers.ru/zenmoney/api"
)

// ...
app.Commands = []cli.Command{
    {
        Name:  "auth",
        Usage: "get tokens",
        Action: func(c *cli.Context) {
            api.Auth(c)
        },
    },
}
// ...

На этом с авторизацией можно заканчивать. Если вас интересует больше информации по OAuth, то на странице документации zenmoney приводится несколько отличных ссылок, которые могут многое прояснить.

Список транзакций

Теперь у нас есть все необходимые ключи и мы можем спокойно выполнять запросы к API. Для начала, сделаем функцию, которая будет красиво выводить список последних транзакций.

Нам нужно добавить еще одну команду в файле main.go

package main

app.Commands = []cli.Command{
    // ...
    {
        Name:  "list, l",
        Usage: "show list of transaction",
        Action: func(c *cli.Context) {
            api.TransactionList(consumer)
        },
    },
    // ...
}

Теперь реализуем запрос к API в методе api.TransactionList().

Так как, у нас уже есть ключи для авторизации, то неплохо было бы подготовить указатель на oauth.AccessToken для использования в запросах к API.

package api

import (
    // ...
    "io/ioutil"
)

func TransactionList(c *oauth.Consumer) {
    access := &oauth.AccessToken{Token: "xxxxx", Secret: "xxxxx"}

    response, err := c.Get(
        "http://api.zenmoney.ru/v1/transaction/",
        map[string]string{},
        access)
    if err != nil {
        log.Fatal(err)
    }
    defer response.Body.Close()

    bits, err := ioutil.ReadAll(response.Body)
    fmt.Println("Транзакции: ", string(bits))
}

В примере выше мы напрямую задаем Token и Secret. Это совсем не правильный подход, так как у нас уже сохраненный JSON с правильными ключами. Давайте будем использовать его.

func TransactionList(c *oauth.Consumer) {
    file, err := os.Open(folder + "/keys.json")
    if err != nil {
        log.Fatal(err)
    }

    access := &oauth.AccessToken{}
    if err = json.NewDecoder(file).Decode(access); err != nil {
        log.Fatal(err)
    }
    // ...
}

Инициализацию переменной folder мы вынесли в функции init() пакета 4gophers.ru/zenmoney/api. Она нам еще не раз пригодится и со временем у нас добавятся еще переменные, которые будут нужны в разных функциях.

package api

var folder string

func init() {
    var err error
    folder, err = osext.ExecutableFolder()
    if err != nil {
        log.Fatal(err)
    }
}

Обратите внимание на URL для обращение к API:

response, err := c.Get(
    "http://api.zenmoney.ru/v1/transaction/",
    map[string]string{},
    access)

Тут стоит сделать небольшое лирическое отступление. К сожалению, на момент написания статьи, в документации была ошибка, и в разделе "Транзакции" нам предлагали использовать url /v1/instrument/currency/, что конечно же не верно(хотя описание структуры именно для транзакции). Путем непродолжительных логический размышлений было обнаружено, что транзакции можно получить по /v1/transaction/. Надеюсь, разработчики API в ближайшее время пофиксят эту ошибку в документации.

И так, теперь у нас есть большой JSON со всеми транзакциями. Давайте превратим его в список структур. Для этого нам нужно описать саму структуру транзакции. Будем надеяться, что тут нас документация не подведет.

Вот так выглядит одна транзакция:

type Transaction struct {
    Id                int    `json:"id"`
    AccountIncome     int    `json:"account_income"`
    AccountOutcome    int    `json:"account_outcome"`
    Income            string `json:"income"`
    Outcome           string `json:"outcome"`
    Comment           string `json:"comment"`
    InstrumentIncome  int    `json:"instrument_income"`
    InstrumentOutcome int    `json:"instrument_outcome"`
    TypeIncome        string `json:"type_income"`
    TypeOutcome       string `json:"type_outcome"`
    Direction         int    `json:"direction"`
    Group             string `json:"tag_group"`
    Date              string `json:"date"`
    Category          int    `json:"category"`
}

type Transactions []Transaction

Кажется подозрительным, что Income и Outcome мы описали как string, хотя на самом деле это числовые типы. Тут имеет место еще одна не состыковка в API: нам эти параметры приходят в виде строк. Конечно, в таком несерьезном приложении это мелочь, но если вы захотите выполнять какие-то операции с этими полями, то имейте ввиду, все не так просто. Дальше будут описаны еще интересные нюансы, связанные с транзакциями и их представлением в виде JSON.

Список таких структур ([]Transaction) нам нужно возвращать из функции TransactionList():


func TransactionList(c *oauth.Consumer) Transactions { // ... bits, err := ioutil.ReadAll(response.Body) tarnsactions := make(Transactions, 0) json.Unmarshal(bits, &tarnsactions) return tarnsactions }

Конечно же, мы хотим в удобном формате просматривать все наши транзакции. Для этого воспользуемся пакетом text/tabwriter. Добавим немного кода после вызова TransactionList():

transactions := api.TransactionList(consumer)
w := new(tabwriter.Writer)
w.Init(os.Stdout, 4, 8, 1, '\t', 0)
fmt.Fprintln(w, "Id\tDate\tIncome\tOutcome\tGroup\t")

for _, transacrtion := range transactions {
    fmt.Fprintln(w, strconv.Itoa(transacrtion.Id)+"\t"+
        transacrtion.Date+"\t"+
        transacrtion.Income+"\t"+
        transacrtion.Outcome+"\t"+
        transacrtion.Group+"\t")
}

fmt.Fprintln(w)
w.Flush()

Вывод транзакций, полученных от API, выглядит как симпатичная табличка:

И тут у нас есть еще одна большая ложка дегтя. Вы наверняка заметили, что у нашей структуры есть поле Transaction.Category, но оно всегда будет равно 0. А вот те категории, которые вы указываете в веб приложении, будут совсем в других полях. Если, при добавлении транзакции, вы указали только одну категорию, то ее id будет в поле Transaction.Group(в JSON это поле tag_group), а если указали несколько категорий, то они будут в JSON поле tag_groups в виде списка. В этом, конечно, ничего страшного нет. Но вот беда, никак нельзя получить данные по этим тегам, даже названия. Да, в API есть метод http://api.zenmoney.ru/v1/category/ (кстати, тут нашлась вторая ошибка в документации: в разделе "Категории" указан URL /v1/account/), но id этих категорий никак не соответствуют id tag_group. И нет никакой возможности связать категории с их названиями. Возможно, я не обладаю неким тайным знанием и на самом деле существует метод для получения инфы по tag_group, но у меня ничего не получилось.

А это значит, что вместо вменяемых названий категорий, у нас буду просто id. И так сойдет (с).

Добавление новой транзакции

В принципе, все основные идеи работы с API уже описаны. Все что нам нужно: прочитать токены из файла, сделать запрос к апи, обработать ответ. Для добавления новой транзакции нужно отправить POST запрос с JSON, сформированным из нашей структуры Transaction, на URL /v1/transaction/.

Но тут тоже не все так просто. Для правильного добавления транзакции, нам нужно указать номер счета(например у меня есть "наличные" и "карта"). К счастью, все счета мы можем спокойно получить с помощью самого API.

Реализуем функцию api.AccountList(), которая будет возвращать map всех существующих аккаунтов по аналогии с транзакциями.

// ...
type Account struct {
    Id         int    `json:"id"`
    Type       string `json:"type"`
    Title      string `json:"title"`
    Balance    string `json:"balance"`
    Instrument int    `json:"instrument"`
}

type Accounts map[string]Account

// ...

func AccountList(c *oauth.Consumer) Accounts {
    access := getAccess()

    response, err := c.Get(
        "http://api.zenmoney.ru/v1/account",
        map[string]string{},
        access)
    if err != nil {
        log.Fatal(err)
    }
    defer response.Body.Close()

    bits, err := ioutil.ReadAll(response.Body)

    accounts := make(Accounts, 0)
    json.Unmarshal(bits, &accounts)

    fmt.Println(accounts)

    return accounts
}

И теперь в main.go добавляем новую команду и вызов функции api.AccountList().

{
    Name:      "account",
    ShortName: "ac",
    Usage:     "show auth url and save tokens",
    Action: func(c *cli.Context) {
        accounts := api.AccountList(consumer)

        w := new(tabwriter.Writer)
        w.Init(os.Stdout, 4, 8, 1, '\t', 0)
        fmt.Fprintln(w, "Id\tTitle\tBalance\t")

        for _, account := range accounts {
            fmt.Fprintln(w, strconv.Itoa(account.Id)+"\t"+
                account.Title+"\t"+
                account.Balance+"\t")
        }

        fmt.Fprintln(w)
        w.Flush()
    },
},

Да да, это очень похоже на отображение транзакций. Аккаунты будут выводиться аналогично транзакциям в красивой табличке. Для нас самое важное - это поле Id. Значение из этого поля нужно будет для создания новой транзакции, мы будем указывать его как Transaction.AccountIncome и Transaction.AccountOutcome

Можно переходить к самой реализации добавления.

func TransactionAdd(c *oauth.Consumer, 
                    dir string, 
                    amount, account int) Transaction {
    access := getAccess()

    income := 0
    outcome := 0
    direction := 0

    if dir == "in" {
        direction = 1
        income = amount
    } else {
        direction = -1
        outcome = amount
    }

    if dir == 0 {
        log.Fatal("Undefined direction")
    }

    response, err := c.Put(
        "http://api.zenmoney.ru/v1/transaction/",
        `{
            "account_income":`+strconv.Itoa(account)+`,
            "account_outcome":`+strconv.Itoa(account)+`,
            "income":`+strconv.Itoa(income)+`,
            "outcome":`+strconv.Itoa(outcome)+`,
            "comment":"",
            "instrument_income":2,
            "instrument_outcome":2,
            "type_income":"cash",
            "type_outcome":"cash",
            "direction": `+strconv.Itoa(direction)+`,
            "date":"`+time.Now().Format("2006-01-02")+`"
            }`,
        map[string]string{},
        access)

    if err != nil {
        log.Fatal(err)
    }

    defer response.Body.Close()

    transaction := Transaction{}

    bits, err := ioutil.ReadAll(response.Body)
    if err != nil {
        log.Fatal(err)
    }

    err = json.Unmarshal(bits, &transaction)
    if err != nil {
        log.Fatal(err)
    }

    return transaction
}

Да, уважаемый читатель, это страх и ужас. Мы не можем использовать структуру transaction, так как, когда мы получаем список транзакций, то JSON поля income и outcome это строки, а при добавлении они становятся интами. Вот так вот. Как вариант, можно создать отдельную структуру транзакции, которую можно использовать для добавления, но это не делает код значительно лучше.

Если у вас есть идеи как можно упростить эту функцию, милости просим в комментарии.

При добавлении транзакции нам нужно указывать суму, счет и направление(расход это или доход). Было бы круто, указывать категорию, но, к сожалению, пока не понятно как это сделать. Конечно, можно получить список прям с фронтенда веб версии, но это уж чересчур хардкорно.

Удаление

Слава Богу, тут все просто. Все что нам нужно - это послать DELETE запрос с указанием id транзакции.

Добавляем новую команду для работы с транзакциями:

{
    Name:      "remove",
    ShortName: "rm",
    Usage:     "remove an existing transaction",
    Action: func(c *cli.Context) {
        if err := api.TransactionDelete(consumer, c.String("id")); err != nil {
            fmt.Println(err)
        }
        fmt.Println("Removed!")
    },
    Flags: []cli.Flag{
        cli.StringFlag{
            Name:  "id",
            Value: "",
            Usage: "transaction id",
        },
    },
},

И реализуем функцию удаления. Вторым параметром функция принимает id транзакции для удаления.

// TransactionDelete remove transaction by id
func TransactionDelete(c *oauth.Consumer, id string) error {
    access := getAccess()
    response, err := c.Delete(
        "http://api.zenmoney.ru/v1/transaction/"+id, 
        map[string]string{}, 
        access)
    if err != nil {
        return err
    }

    defer response.Body.Close()

    return nil
}

Заключение

С первого взгляда казалось, что работа с этим API достаточно тривиальна, но мелкие проблемы и опечатки в документации все портят.

За кадром остался обзор расширенного API для упрощенной синхронизации клиента и сервера, но тут не хватило бы размеров статьи :)

В целом, с API zenmoney можно сносно работать. И я надеюсь, что в будущем API будет только улучшаться, а ошибки исправят.

Как всегда, все примеры к статье можно найти на github.