Перейти к содержанию

GraphQL

POST /graphql

GraphQL API рядом с REST API /v1 — для клиентов, которые хотят сами выбирать получаемые поля или объединять несколько запросов в один обмен с сервером. Он работает на тех же сервисах, что и REST: то же кэширование, объединение запросов, валидация и feature flags, те же данные.

Он включён по умолчанию; отключить его можно через ENABLE_GRAPHQL=false, после чего /graphql отвечает 404.

Эндпоинт

Метод Что
POST /graphql {"query": …, "variables": {…}, "operationName": …} в виде JSON или JSON-массив таких объектов (пакет, см. Лимиты)
GET /graphql В браузере — IDE GraphiQL. С ?query=…&variables=… — выполняет запрос

GraphiQL, GET-запросы и интроспекция схемы доступны, пока включена документация; DISABLE_DOCS=true отключает все три (POST продолжает работать).

curl http://localhost:8000/graphql \
  -H 'content-type: application/json' \
  -d '{"query": "{ body(channel: \"durov\") { channel { title { string } } } }"}'
{"data": {"body": {"channel": {"title": {"string": "Pavel Durov"}}}}}

Схема

Каждое корневое поле повторяет REST-эндпоинт и возвращает те же данные:

Поле Возвращает Аналог в REST
body(channel, position) ChannelBody GET /v1/body/{channel}
more(channel, direction, position) More GET /v1/more/{channel}/{direction}/{position}
post(channel, id) Post GET /v1/post/{channel}/{identifier}
preview(channel) Preview GET /v1/preview/{channel}
previews(channels) [Preview!] POST /v1/previews
feed(channels) [FeedPost!] POST /v1/feed

Имена полей совпадают с ключами JSON в REST (preview_link, to_message, is_verified, …), так что результат GraphQL — это в точности REST-ответ без полей, которые вы не запросили. Поэтому модели данных описывают оба API. Отличия:

  • preview и previews возвращают объекты Preview с дополнительным полем username; previews — это список (в порядке запроса, без неизвестных каналов), а не словарь с именами пользователей в качестве ключей.
  • Элементы ленты — FeedPost: Post плюс channel, score, duplicates и topic (в REST: _score, _duplicates, _topic). duplicates — пустой список, если в пост ничего не свёрнуто. Поля, которых нет в сырых данных ленты, равны null.
  • Фиксированные наборы строк — это enum, значения которых совпадают со строками REST: MediaType (image, video, …), EntityType, ReactionType и Direction (after, before).
  • Date.unix — скаляр Long: обычное целое число JSON, которое может выходить за пределы 32 бит.

Корневые поля допускают null: если одно из них завершается ошибкой, оно равно null и получает запись в errors, а остальные поля операции всё равно возвращают данные.

Полная схема (SDL)
"""Channel profile shown above the posts."""
type Channel {
  username: String!
  title: ParsedAndRaw!
  description: ParsedAndRaw
  avatar: String
  counters: Counters!

  """Header labels, e.g. `verified`."""
  labels: [String!]
}

"""Channel page: profile, posts and pagination."""
type ChannelBody {
  channel: Channel!
  content: ChannelContent!
  meta: Meta!
}

"""Posts of a channel page."""
type ChannelContent {
  posts: [Post!]!
}

"""Channel statistics as formatted by Telegram (e.g. `1.2M`)."""
type Counters {
  subscribers: String!
  photos: String
  videos: String
  files: String
  links: String
}

"""Publication time."""
type Date {
  """ISO-8601 timestamp with offset."""
  string: String!
  unix: Long!
}

"""Load posts newer (`after`) or older (`before`) than a position."""
enum Direction {
  after
  before
}

"""Duration of time-based media."""
type Duration {
  """Human-readable value as shown by Telegram, e.g. `2:30`."""
  formatted: String!

  """Duration in seconds."""
  raw: Int!
}

"""Kind of a formatting entity."""
enum EntityType {
  italic
  bold
  code
  spoiler
  strikethrough
  underline
  text_link
  url
  pre
  emoji
  animoji
  hashtag
}

"""A repost of the same story collapsed into a feed post."""
type FeedDuplicate {
  channel: String
  id: Int!
}

"""A feed post with its channel and ranking score."""
type FeedPost {
  id: Int!
  content: PostContent!
  footer: Footer!
  forwarded: Forwarded
  view: String!
  channel: Channel!

  """
  Ranking score (`_score` in the REST feed); the feed is in ranking order.
  """
  score: Float!

  """Reposts of the same story collapsed into this post (`_duplicates`)."""
  duplicates: [FeedDuplicate!]!

  """Subject shared with other feed posts (`_topic`), if any."""
  topic: FeedTopic
}

"""Subject a feed post shares with other posts of the feed."""
type FeedTopic {
  id: Int!
  keywords: [String!]!
}

"""Post metadata shown under the message."""
type Footer {
  views: String
  edited: Boolean
  author: ParsedAndRaw
  date: Date!
}

"""Original source of a forwarded post."""
type Forwarded {
  """Source channel or user; empty when the sender hides their name."""
  name: ParsedAndRaw!
  url: String

  """Signature shown next to the source, e.g. the bot that posted it."""
  author: ParsedAndRaw
}

"""Inline keyboard button."""
type InlineButton {
  title: String!
  url: String!
}

"""Integer that may exceed 32 bits (Unix timestamps)."""
scalar Long

"""Media attachment of a post."""
type MediaItem {
  url: String
  thumb: String

  """Encoded waveform of a voice message."""
  waves: String
  duration: Duration
  type: MediaType!

  """False when Telegram does not expose the file (e.g. a too large video)."""
  available: Boolean
}

"""Kind of a media attachment."""
enum MediaType {
  image
  video
  voice
  roundvideo
  sticker
  gif
}

"""Pagination metadata."""
type Meta {
  offset: Offset!
}

"""A batch of posts loaded relative to a position."""
type More {
  posts: [Post!]!
  meta: Meta!
}

"""Pagination cursors: pass them to `more` to load adjacent posts."""
type Offset {
  before: Int
  after: Int
}

"""Text in two forms: plain `string` and the original `html`."""
type ParsedAndRaw {
  string: String!
  html: String!
}

"""Poll attached to a post."""
type Poll {
  question: String!
  type: String
  votes: String
  options: [PollOption!]!
}

"""One answer option of a poll."""
type PollOption {
  name: String!
  percent: Int!
}

"""A channel post."""
type Post {
  id: Int!
  content: PostContent!
  footer: Footer!
  forwarded: Forwarded
  view: String!
}

"""Everything a post can contain."""
type PostContent {
  text: Text
  media: [MediaItem!]
  poll: Poll
  inline: [InlineButton!]
  reply: Reply
  preview_link: PreviewLink
  reacts: [Reaction!]
}

"""Channel preview."""
type Preview {
  """The requested channel username."""
  username: String!
  channel: PreviewChannel
}

"""Channel summary from the `t.me/<channel>` landing page."""
type PreviewChannel {
  title: String!
  subscribers: String!
  description: String
  avatar: String
  is_verified: Boolean!
}

"""Link preview card generated by Telegram."""
type PreviewLink {
  title: String
  url: String!
  site_name: String
  description: ParsedAndRaw
  thumb: String
}

"""Read-only access to public Telegram channels."""
type Query {
  """
  Channel profile, its latest posts (or posts around `position`) and pagination. REST: `GET /v1/body/{channel}`.
  """
  body(
    """Channel username, e.g. `durov`."""
    channel: String!

    """Open the page around this post ID."""
    position: Int = null
  ): ChannelBody

  """
  Posts newer or older than `position` (anchor excluded); use `meta.offset` to keep paginating. REST: `GET /v1/more/{channel}/{direction}/{position}`.
  """
  more(
    """Channel username, e.g. `durov`."""
    channel: String!
    direction: Direction!

    """Anchor post ID."""
    position: Int!
  ): More

  """A single post. REST: `GET /v1/post/{channel}/{identifier}`."""
  post(
    """Channel username, e.g. `durov`."""
    channel: String!

    """Post ID."""
    id: Int!
  ): Post

  """
  Short channel summary: title, subscribers, description, avatar. REST: `GET /v1/preview/{channel}`. Requires `ENABLE_PREVIEWS`.
  """
  preview(
    """Channel username, e.g. `durov`."""
    channel: String!
  ): Preview

  """
  Previews of several channels, fetched in parallel; unknown channels are omitted. REST: `POST /v1/previews`. Requires `ENABLE_PREVIEWS`.
  """
  previews(
    """1 to 100 channel usernames."""
    channels: [String!]!
  ): [Preview!]

  """
  Posts of several channels ranked by score, best first. REST: `POST /v1/feed`. Requires `ENABLE_FEED`.
  """
  feed(
    """1 to 100 channel usernames."""
    channels: [String!]!
  ): [FeedPost!]
}

"""One reaction counter of a post."""
type Reaction {
  count: String!
  type: ReactionType!
  emoji: String
  emoji_id: String
  emoji_image: String
}

"""Kind of a reaction."""
enum ReactionType {
  telegram_stars
  emoji
  custom_emoji
}

"""The message this post replies to."""
type Reply {
  cover: String
  name: ParsedAndRaw!
  text: ParsedAndRaw!
  to_message: Int!
}

"""Post text with optional formatting entities."""
type Text {
  string: String!
  html: String!
  entities: [TextEntity!]
}

"""Formatting entity inside a post text; offsets refer to `string`."""
type TextEntity {
  offset: Int!
  length: Int!
  type: EntityType!
  language: String
  url: String
}

Примеры

ID последних постов канала, их просмотры и курсор пагинации:

{
  body(channel: "durov") {
    content { posts { id footer { views date { unix } } } }
    meta { offset { before } }
  }
}

Следующая страница, с переменными:

query Older($channel: String!, $before: Int!) {
  more(channel: $channel, direction: before, position: $before) {
    posts { id content { text { string } } }
    meta { offset { before } }
  }
}
{"channel": "durov", "before": 531}

Несколько каналов в одном запросе с алиасами и фрагментом:

{
  durov: body(channel: "durov") { ...Latest }
  telegram: body(channel: "telegram") { ...Latest }
  previews(channels: ["durov", "telegram"]) {
    username
    channel { title subscribers is_verified }
  }
}

fragment Latest on ChannelBody {
  channel { title { string } }
  content { posts { id content { media { type url thumb } } } }
}

Ранжированная лента (требует ENABLE_FEED):

{
  feed(channels: ["durov", "telegram", "stranaua"]) {
    id
    score
    channel { username }
    content { text { string } }
  }
}

Пакет из двух операций в одном HTTP-запросе:

[
  {"query": "{ post(channel: \"durov\", id: 531) { view } }"},
  {"query": "{ preview(channel: \"telegram\") { channel { title } } }"}
]

Ответ — массив с одним результатом на операцию, в том же порядке.

Эффективность

  • Каждая страница загружается не больше одного раза за HTTP-запрос: одинаковые корневые поля (алиасы, повторяющиеся фрагменты, операции пакета) разделяют один результат, а поля, которым одновременно нужна одна и та же страница (body(position: 531) и post(id: 531)), разделяют один запрос к источнику.
  • Корневые поля вычисляются конкурентно; previews и feed загружают свои каналы параллельно (по-прежнему в пределах TELEGRAM_MAX_CONCURRENCY).
  • Ответы берутся из тех же кэшей, что и в REST (см. Свежесть данных), так что совместное использование обоих API не удваивает нагрузку на t.me. GET-запросы, пришедшие через Cloudflare, получают тот же Cache-Control, что и ответы REST.

Лимиты

Каждая операция проверяется до того, как что-либо загружается. Все лимиты настраиваются:

Настройка По умолчанию Отклоняет
GRAPHQL_MAX_DEPTH 10 Выборки, вложенные глубже этого значения ниже корневого поля (корневое поле = 0, интроспекция не считается)
GRAPHQL_MAX_COMPLEXITY 1500 Операции, стоимость которых превышает это значение
GRAPHQL_MAX_ALIASES 15 Документы с большим числом алиасов
GRAPHQL_MAX_TOKENS 2000 Документы длиннее этого числа токенов
GRAPHQL_MAX_BATCH_SIZE 10 Пакеты с большим числом операций (400 Too many operations)

Стоимость — 1 за каждое выбранное поле (с раскрытыми фрагментами) плюс 10 за каждый канал, который может загрузить корневое поле: 10 для body, more, post и preview и 10 × число каналов для previews и feed (фактическая длина списка, в том числе когда он передан переменной). feed по максимальным 100 каналам стоит около 1 000 + его поля, поэтому укладывается в лимит по умолчанию, а два таких — уже нет.

Лимиты применяются к каждой операции пакета. Ограничение частоты запросов (ENABLE_RATE_LIMIT) считает HTTP-запросы, поэтому пакет считается один раз.

Ошибки

Ошибки GraphQL возвращаются с HTTP 200 в списке errors, у каждой есть стабильный extensions.code:

{
  "data": {"body": null},
  "errors": [
    {
      "message": "Requested Telegram resource was not found",
      "locations": [{"line": 1, "column": 3}],
      "path": ["body"],
      "extensions": {"code": "NOT_FOUND"}
    }
  ]
}
extensions.code Статус в REST Причина
BAD_INPUT 422 Некорректное имя пользователя, ID/позиция поста вне 1..10000000 или число каналов не в диапазоне 1–100
NOT_FOUND 404 Канал или пост не существует (или канал приватный)
BAD_GATEWAY 502 t.me вернул страницу, которую парсер не смог разобрать
UNAVAILABLE 503 t.me недоступен или ответил ошибкой
FEATURE_DISABLED 503 preview/previews при ENABLE_PREVIEWS=false, feed при ENABLE_FEED=false
QUERY_TOO_COMPLEX — Операция стоит больше, чем GRAPHQL_MAX_COMPLEXITY
GRAPHQL_PARSE_FAILED — Синтаксическая ошибка или больше GRAPHQL_MAX_TOKENS токенов
GRAPHQL_VALIDATION_FAILED — Неизвестное поле или аргумент, неверный тип, лимит глубины/алиасов, интроспекция при выключенной документации
INTERNAL_SERVER_ERROR 500 Неожиданная ошибка сервера; сообщение скрывается, а подробности пишутся в лог

Как и в REST, ввод проверяется раньше feature flags, поэтому некорректные аргументы дают BAD_INPUT, даже когда функция отключена. Ошибки из строк BAD_INPUT…FEATURE_DISABLED — это ошибки выполнения: они обнуляют только своё поле. Остальные возникают до выполнения и возвращают "data": null.

Запросы, которые вообще не являются GraphQL, отклоняются с HTTP-статусом: 400 для тела, которое не является JSON, не содержит query или представляет собой слишком большой пакет; 400 для GET-запросов при выключенной документации; 429 при срабатывании ограничения частоты запросов.