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 } } } }"}'
Схема¶
Каждое корневое поле повторяет 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 } }
}
}
Несколько каналов в одном запросе с алиасами и фрагментом:
{
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 при срабатывании ограничения частоты запросов.