Всё началось очень мирно. Мне нужен был календарь, который не разваливается на бытовых вещах: не путает часовые пояса, не теряет повторяющиеся события, не забывает про изменения и не заставляет руками переносить встречи с устройства на устройство.

Сначала это был проект «для себя». Один календарь, один аккаунт, один провайдер. На такой задаче легко обмануться и решить, что всё получится быстро: календарь же не база данных космического корабля, а довольно понятная вещь. Но календарные проекты имеют одну неприятную особенность: как только ими начинают пользоваться не только вы, они быстро становятся частью чужой жизни. Сначала семейные планы, потом общие встречи с друзьями. Потом ещё один сервис, потом ещё один. И в какой-то момент уже нужно не просто хранить события, а синхронизировать их между разными облаками и при этом не ошибаться в самых человеческих местах — в датах, повторениях, конфликтующих правках и часовых поясах.

Именно так я и пришёл к CalDAV. На бумаге он выглядит довольно несложно: RFC 4791 описывает его как расширение WebDAV для доступа, управления и совместного использования календарной и планировочной информации на основе iCalendar, а RFC 6578 добавляет механизм синхронизации коллекций через sync-token. Всё это звучит так, будто нужно просто реализовать HTTP-методы и XML-отчёты — и дело сделано. На практике CalDAV оказывается не «ещё одним REST API», а протоколом состояния, где особенно важны нюансы того, как сервер описывает коллекции, изменения и ограничения.

Почему я вообще не остался на «родном» API Google

Если бы задача касалась только Google, всё выглядело бы совсем иначе. У Google есть собственный Calendar API, и для одного провайдера он действительно удобнее: JSON вместо XML, привычная пагинация, прямые ресурсы, понятные ответы. Когда работаешь только с Google, смысла использовать CalDAV нет. Но как только появляется цель сделать один и тот же клиент для нескольких сервисов, картина меняется.

У Google CalDAV тоже есть, но их документация довольно прямо говорит, что доступ к нему идёт только по HTTPS и только через OAuth 2.0. Basic Authentication сервер отвергает с 401 Unauthorized. В той же справке Google отдельно пишет, что CalDAV-интерфейс у них не поддерживает VTODO и VJOURNAL, что после первой синхронизации клиент должен перейти на режим RFC 6578, и что CTag можно использовать как быстрый индикатор того, изменилась ли коллекция. Ещё там описаны две стартовые точки: либо principal collection, либо конкретная calendar collection.

С Apple история уже другая. Apple официально рекомендует app-specific passwords для сторонних приложений, которые обращаются к данным iCloud, включая календарь. Такие пароли работают только вместе с двухфакторной аутентификацией, создаются в аккаунте отдельно от основного пароля и могут быть отозваны поштучно или все сразу; при смене основного пароля Apple Account все такие пароли сбрасываются автоматически. Это не делает интеграцию проще, но делает её предсказуемой с точки зрения политики безопасности.

Поэтому для клиента, который должен одинаково жить с несколькими облаками, CalDAV оказался не компромиссом, а почти единственным общим языком. Для Google можно использовать его CalDAV-интерфейс, для Apple — CalDAV и app-specific passwords, а дальше уже приходится принимать, что каждое облако будет вести себя по-своему даже при одинаковых названиях методов и одинаковых словах в стандартe.

Что такое CalDAV, если смотреть на него не как на RFC, а как на рабочий инструмент

Если упростить до самого полезного уровня, CalDAV — это способ работать с календарями поверх HTTP. Данные хранятся в формате iCalendar, сервер выставляет календарь как коллекцию ресурсов, а клиент работает с ними через WebDAV-операции и отчёты. RFC 4791 именно так и описывает модель: календарные объекты как ресурсы, коллекции как календари, а доступ к ним — через HTTP и XML. При этом сам стандарт сразу предупреждает, что одна и та же модель может быть реализована по-разному, потому что детали маппинга календарных данных в WebDAV-ресурсы оказываются важны для совместимости.

Поэтому CalDAV удобно воспринимать не как «эндпоинты для календаря», а как контракт на то, как сервер хранит и показывает календарное состояние. Если смотреть на него именно так, становится понятнее, почему одни и те же действия у разных провайдеров дают разные ответы. В одном случае это «нормальный» 412, в другом — XML-ошибка с 400, в третьем — неожиданный 502 или 504 под нагрузкой. Протокол один, а эксплуатационных реализаций — несколько, и они довольно свободно трактуют детали. Это, собственно, и есть та самая дикая природа.

Discovery: почему нельзя просто взять URL и начать работать

Первое, что ломает иллюзию «сейчас просто подключусь к календарю», — это discovery. RFC 4791 описывает схему, в которой клиент должен найти current-user-principal, затем через него получить calendar-home-set, а уже потом перечислить календарные коллекции. В стандартном варианте это делается через PROPFIND и REPORT, а в примерах RFC показано, что клиент должен уметь узнавать и себя, и свой домашний набор календарей, прежде чем вообще переходить к чтению событий. Плюс RFC 4791 требует, чтобы сервер, поддерживающий календарные функции, объявлял calendar-access в ответе OPTIONS на ресурсы, которые эти функции поддерживают.

В реальном клиенте это означает не один запрос, а цепочку. Сначала проверка, что URL вообще похож на CalDAV. Потом попытка узнать, кто мы такие в контексте сервера. Потом поиск домашней коллекции. Потом список календарей. И только после этого можно перейти к содержимому. На красивой схеме это выглядит как аккуратная дорожка из трёх стрелок, а в коде — как набор запасных веток, фолбэков и таймаутов.

Отдельно полезно помнить, что RFC 4791 описывает calendar-home-set именно как путь, который позволяет клиенту узнать «кто я» и «где мои календари», а OPTIONS — как способ быстро определить, поддерживает ли сервер календарные возможности вообще. Это очень практичная часть стандарта: она нужна не для красоты, а для того, чтобы клиент не начал слать тяжёлые запросы в неподходящее место.

Аутентификация: два разных мира, которые приходится поддерживать одновременно

Сам CalDAV почти ничего не диктует о том, как пользователь должен доказывать серверу, что это именно он. Это решает провайдер, а провайдеры, как водится, решают по-разному.

В моём случае это свелось к двум сценариям.

Первый — OAuth 2.0. Так работает Google, и это видно прямо в их CalDAV guide: подключение должно идти по HTTPS, с OAuth 2.0, а Basic Auth сервер отклоняет. В документации Google также указано, что для CalDAV у них есть две стартовые точки: либо principal collection, либо конкретная calendar collection по URI вида https://apidata.googleusercontent.com/caldav/v2/CALENDAR_ID/user или .../events. Это очень важная практическая вещь: даже если сам протокол общий, вход в него у Google завязан на их собственный OAuth-контур и их адреса.

Второй сценарий — пароль приложения. У Apple это официальный способ для сторонних приложений, которым нужен доступ к календарю iCloud. Пользователь создаёт отдельный пароль, приложение использует его вместо основного, а потом этот пароль можно отозвать отдельно от всего остального. На практике это удобнее, чем просить у пользователя основной пароль, но UX получается не самым простым: нужен отдельный шаг в кабинете, отдельная сущность, отдельная логика восстановления.

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

Что сервер должен сам сообщать о себе

CalDAV хорош ещё и тем, что он не оставляет клиента совсем вслепую. RFC 4791 описывает ряд свойств календарной коллекции, которые сервер может и должен использовать, чтобы сообщить ограничения и возможности. Например, supported-calendar-component-set говорит, какие типы компонентов вообще допустимы в коллекции; если сервер объявил только VEVENT, попытка записать туда VTODO должна закончиться ошибкой. Есть и supported-calendar-data, и max-resource-size, а значит сервер может явно ограничивать формат или размер календарного объекта. Для клиента это полезная обратная связь: не надо догадываться о границах коллекции, их можно спросить у сервера.

Отсюда же логически вытекает важная вещь: если сервер говорит, что принимает только определённые компоненты, клиент должен это уважать. Иначе он начнёт «успешно» загружать то, что потом внезапно не найдётся при чтении или не будет принято при обновлении. В календарях, как ни странно, это особенно болезненно, потому что пользователь обычно не замечает проблему до того момента, пока событие не исчезнет или не перестанет обновляться.

Как забирать события и почему всё не заканчивается одним запросом

Для чтения событий CalDAV предлагает calendar-query и calendar-multiget. RFC 4791 делает это довольно чётко: calendar-query — это основной способ искать календарные объекты по фильтру, а calendar-multiget — способ забрать конкретные ресурсы по списку href. И то и другое — обязательные возможности сервера. В ответ на calendar-query сервер может вернуть как WebDAV-поля, так и сам calendar-data, то есть iCalendar-содержимое объекта.

Для полной синхронизации это удобно: можно сразу получить и метаданные, и тело события. Но в реальной жизни полный обход всего календаря на каждом цикле — дорогое удовольствие. Если календарь большой, если у него есть история, если серия повторяющихся событий длинная, если пользователей несколько, а клиент ещё и мобильный, то полная синхронизация быстро становится расточительной. Именно поэтому CalDAV изначально и проектировался не как «запросил всё и забыл», а как протокол, который можно синхронизировать пошагово и с ограничением по диапазону.

Здесь очень помогает time-range. RFC 4791 прямо говорит, что начало диапазона включается, а конец — нет. Это маленькая, но очень важная деталь, потому что она делает поведение предсказуемым на границах. Кроме того, стандарт отдельно рекомендует ограничивать диапазон, чтобы не тащить всю историю календаря без нужды. На практике это означает, что клиент почти всегда работает не со всем календарём, а с окном вокруг текущего времени.

Инкрементальная синхронизация: когда sync-token спасает от лишней работы

Полная синхронизация хороша только один раз — в самом начале. Потом нужен способ понять, что именно изменилось. Для этого RFC 6578 добавляет sync-collection: сервер возвращает только новые, изменённые и удалённые члены коллекции с момента последней синхронизации, а сам токен меняется каждый раз, когда меняется набор ресурсов или их содержимое. Это и есть тот случай, когда протокол начинает вести себя как настоящая система синхронизации, а не как набор HTTP-запросов.

Грубо говоря, клиент запоминает sync-token, а потом просит только дельту. Если токен устарел, сервер возвращает 410 Gone, и тогда клиент обязан сбросить состояние и сделать полную синхронизацию заново. RFC 6578 именно так и описывает этот механизм. Для больших календарей это не просто удобство, а разница между приемлемой и невыносимой нагрузкой на сеть и сервер.

Google в своей CalDAV-документации прямо пишет, что после начальной синхронизации клиент должен перейти на RFC 6578. Там же отдельно поясняется, что CTag — это календарный аналог ресурcного ETag: если коллекция не изменилась, можно не запускать синхронизацию вообще. Для живого клиента это очень полезная связка: сначала дешёвый вопрос «что-то менялось?», потом дорогой вопрос «покажи изменения».

Поддержка инкрементальной синхронизации у провайдеров
Поддержка инкрементальной синхронизации у провайдеров

В моих прогонах это оказалось особенно важно на серверах, где каждый лишний запрос стоит дорого. В идеальной схеме клиент сначала смотрит на CTag, потом только при необходимости идёт в sync-collection, а если сервер не поддерживает инкрементальную синхронизацию, откатывается к полному обходу и сравнению ETag. И это как раз тот компромисс, который в реальном проекте работает лучше любой красивой абстракции.

Почему calendar-query и calendar-multiget — это не одно и то же

В какой-то момент возникает соблазн считать, что calendar-query и calendar-multiget — это просто два способа получить одно и то же. Но у них разные роли. calendar-query ищет объекты по фильтру, а calendar-multiget забирает уже известные ресурсы по списку ссылок. RFC 4791 это не смешивает: запросы похожи по форме, но семантически решают разные задачи. Это очень важно при проектировании клиента, потому что после инкрементальной синхронизации у вас почти всегда появляется именно список ссылок, а не полный набор данных.

Именно поэтому в реальном коде обычно получается так: сначала sync-collection, потом — либо calendar-data прямо из ответа, либо дополнительный calendar-multiget. Универсальность здесь достигается не одним «лучшим» способом, а тем, что клиент умеет переключаться между ними без потери модели данных.

Где я чаще всего ловил несовместимости

Первый классический конфликт — ETag. Он нужен для оптимистичной блокировки: клиент отправляет объект с If-Match, сервер сравнивает версию и либо принимает изменение, либо сообщает о конфликте. RFC 4791 и связанный HTTP-механизм опираются именно на эту модель: ETag идентифицирует состояние ресурса, а не сам ресурс как таковой. В ответах RFC прямо показаны getetag и условия синхронизации по ETag-параметрам.

Доступность ETag у провайдеров
Доступность ETag у провайдеров

В моём проекте это вылезло очень быстро: разные серверы по-разному форматировали ETag. Где-то он приходил в кавычках, где-то без них. Поэтому в клиенте я в итоге разделил внутреннее представление и то, что уходит в HTTP-заголовок. Внутри — нормализованное значение, наружу — всегда формат для заголовка. Это очень скучное решение, но именно такие решения обычно и спасают от бесконечных «почему библиотека падает на ровном месте».

Следующий слой проблем — коды ответа. На хорошем, учебниковом сервере конфликт обновления должен быть 412 Precondition Failed. В реальной жизни часть серверов отвечает иначе, а некоторые ещё и добавляют XML-тело с дополнительным описанием ошибки. Поэтому в клиенте нельзя смотреть только на один статус-код и считать, что всё остальное — это «просто сеть». Иногда это тоже конфликт, только выраженный в другом формате.

Обработка конфликтов у провайдеров
Обработка конфликтов у провайдеров

С удалением ситуация похожая. DELETE на бумаге выглядит тривиально, но при повторном вызове серверы ведут себя по-разному: где-то операция ведёт себя идемпотентно, где-то уже удалённый ресурс вызывает ошибку. В моём коде это означало, что удаление надо делать так, будто повторный DELETE — это не исключение, а один из нормальных сценариев. Иначе retry-логика будет ломать хорошие попытки.

Обработка операции удаления у провайдеров
Обработка операции удаления у провайдеров

SEQUENCE, UID, URL и всё то, что на первый взгляд кажется простым

С SEQUENCE история особенно показательна. В iCalendar это поле должно увеличиваться при значимых изменениях события, но на практике серверы относятся к нему очень по-разному. У меня в проекте оно оказалось полезным только как дополнительный сигнал, а не как единственная опора. Если строить конфликтную логику только на SEQUENCE, очень быстро выясняется, что часть провайдеров просто не играет по этим правилам. Поэтому безопаснее смотреть сначала на ETag, потом на LAST-MODIFIED, и только потом на SEQUENCE.

Поддержка SEQUENCE у провайдеров
Поддержка SEQUENCE у провайдеров

Отдельный класс ошибок — смешать UID и URL ресурса. Очень хочется думать, что если в пути лежит удобный идентификатор, то это и есть логический UID события. Но это не так. URL нужен для адресации операций CalDAV, а UID — для семантической идентификации события в iCalendar. Они могут совпадать, но это не обязательное правило. И если клиент начинает считать их одним и тем же, дальше будет много удивительных эффектов при миграциях, повторных загрузках и синхронизации.

Повторяющиеся события и таймзоны: место, где «улучшать» данные почти всегда вредно

Одна из самых болезненных областей CalDAV — повторяющиеся события. RFC 4791 и iCalendar вообще очень не любят «творческий» пересчёт серии. Если сервер прислал RRULE, EXDATE или RECURRENCE-ID, лучше хранить это ровно в том виде, в котором оно пришло, и возвращать обратно без самодеятельности. Любая попытка «нормализовать» серию в отдельные даты или перестроить её под собственную базу данных очень быстро начинает ломать совместимость между клиентами. RFC 4791 отдельно обсуждает рекуррентность как часть модели данных, а не как удобную оптимизацию на стороне клиента.

С часовыми поясами история ещё коварнее. RFC 4791 описывает calendar-timezone как календарный объект, содержащий ровно один VTIMEZONE, и отдельно показывает, что это не декоративная вставка, а полноценная часть сериализации. Если клиент не умеет корректно хранить и возвращать VTIMEZONE, он начинает сдвигать время событий, и пользователь видит баг уже не в логах, а в календаре. Это один из тех случаев, где ошибка не падает, а просто выглядит как «встреча почему-то уехала на час».

Несколько вещей, которые удобно недооценить заранее

RFC 4791 специально разрешает использование нестандартных X- компонентов, свойств и параметров, а серверы должны поддерживать их в календарных объектах, сохраняемых через PUT. Это очень практичная деталь, потому что сторонний клиент может добавить в объект свои поля, и если вы при обновлении их потеряете, интеграция начнёт ломаться уже в чужих сценариях. Поэтому полезнее хранить iCalendar-объект максимально близко к исходному виду, чем пытаться сделать его «красивым».

Есть и другие маленькие, но важные вещи. RFC 4791 отдельно описывает free-busy-query: это обязательный отчёт, который должен содержать ровно один time-range, работать только на коллекции и возвращать VFREEBUSY с занятыми промежутками. Это не центральная часть моего проекта, но очень хороший пример того, как стандарт одновременно задаёт и форму запроса, и ограничения на то, где его вообще можно применять.

Полезно помнить и про supported-calendar-component-set: если сервер объявил, что коллекция принимает только определённые типы компонентов, то клиент обязан это учитывать ещё до PUT. А max-resource-size даёт серверу право ограничивать размер календарного объекта. Именно такие ограничения потом превращаются в «странные» ошибки при загрузке длинных описаний, массивных приглашений или событий, в которые кто-то добавил слишком много вложенных данных.

Что в итоге получилось

После всех этих экспериментов у меня остался очень простой набор правил, и он, как обычно, гораздо скучнее, чем хочется. Сервер — источник истины. Не надо «улучшать» данные по дороге. Хранить стоит всё, что пришло, включая нестандартные свойства и компоненты. Discovery лучше кэшировать. Инкрементальную синхронизацию надо включать, если она есть, а если её нет — быть готовым к полному обходу. И главное: не рассчитывать, что если один провайдер делает что-то одним способом, то второй обязан вести себя так же.

Сравнение аспектов реализации протокола по провайдерам
Сравнение аспектов реализации протокола по провайдерам

Если свести всё к одной фразе, то CalDAV оказался не столько «способом хранить календари», сколько очень честным напоминанием о том, что стандарты не отменяют различий в реализациях. Протокол один, а реальностей — несколько. И если это принять, клиент начинает работать заметно спокойнее.

Удобные инструменты

Если вы работаете с CalDAV на Go, посмотрите на форк go-webdav — в нём добавлены условные операции, инкрементальная синхронизация и более удобная работа с конфликтами. А для быстрого заполнения и очистки тестовых календарей из CSV пригодится CalDAV Manager.