Изображение обложки с сайта tinykat.cafe
Вступление
В течение некоторого времени я хотел иметь возможность получать текущие данные о спонсорах GitHub с помощью API. API, предлагаемый GitHub, в настоящее время настроен только на GraphQL, поэтому получить данные было довольно сложно, поскольку я не очень хорошо знаком с GraphQL.
Кроме того, настройка GraphQL в проекте, где будет использоваться только один запрос, казалась мне излишеством. В любом случае, я обнаружил, что мы можем отправить запрос GraphQL в обычном запросе REST API, используя параметр body. Для справки я использовал этот репозиторий.
TL:DR; Вы можете найти доработанный код проекта по адресу https://github.com/jahirfiquitiva/sponsors-edge-api.
Запрос GraphQL
У меня ушло довольно много времени на изучение документации GitHub GraphQL API и создание запроса, чтобы получить всю необходимую информацию, поэтому я пропущу подробности. В основном я использовал их GraphQL Explorer и просмотрел множество Interface
s, Object
s и других данных, выполняя процесс проб и ошибок, чтобы получить окончательный запрос.
Некоторые из данных, которые я использовал, были следующими:
- Пользователь
- Спонсируемый
- SponsorConnection
- SponsorsListing
- SponsorsTierConnection
- SponsorsTier
- SponsorsTierAdminInfo
- Спонсорство
- Спонсор
Окончательный запрос выглядит следующим образом:
{
user(login: "jahirfiquitiva") {
sponsorsListing {
id
tiers(first: 20) {
nodes {
... on SponsorsTier {
id
adminInfo {
sponsorships(first: 100) {
totalRecurringMonthlyPriceInDollars
nodes {
... on Sponsorship {
sponsorEntity {
... on User {
login
avatarUrl
name
websiteUrl
}
... on Organization {
login
avatarUrl
name
websiteUrl
}
}
tierSelectedAt
}
}
}
}
monthlyPriceInDollars
isOneTime
isCustomAmount
name
description
}
}
}
}
... on Sponsorable {
sponsors {
totalCount
}
}
}
}
Что делает этот запрос?
В основном, он получает данные о спонсорах GitHub и общее количество спонсоров для пользователя, определенного в начале запроса: user(login: "jahirfiquitiva")
здесь я использовал свое имя пользователя GitHub, но вы можете заменить его своим.
Я получаю данные листинга, потому что хочу сгруппировать спонсоров по их уровню, а также узнать цену уровня и другие детали. Если вам нужно знать только спонсоров, независимо от их уровня, можно построить более простой запрос, используя свойство
sponsors
в конце запроса выше.
Из данных списка спонсоров я получаю различные уровни. Уровень — это, по сути, вариант пожертвования, например, у меня есть 6 ежемесячных уровней: звезда, хрустальный шар, ракета, робот, молния и бриллиант, основанные на разной цене. Это потому, что я «назвал» их, хотя на самом деле у них нет названия по умолчанию. Это просто помогает мне распределять спонсоров по категориям.
tiers(first: 20)
вернет первые 20 уровней из списка ваших спонсоров. Как я уже сказал, у меня есть 6 ежемесячных и 3 разовых тиеров: всего 9 тиеров, так что даже 20 — это больше, чем нужно. Кроме того, у вас может быть только 10 опубликованных ежемесячных и 10 опубликованных разовых тиеров.
С каждого тиера я получаю следующую информацию:
-
-
- Кроме того,
sponsorEntity
включаетtierSelectedAt
, который определяет дату и время, когда данный уровень был выбран для спонсорства.
- Кроме того,
-
Если вам нужна дополнительная информация о спонсорах или спонсорстве, вы можете изучить документацию.
Авторизация
Прежде чем мы действительно используем этот запрос для доступа к этим данным, мы должны создать Personal Access Token, поскольку для этого требуется авторизация.
Для этого зайдите в Настройки вашего аккаунта на GitHub, затем перейдите в Настройки разработчика и, наконец, выберите Personal Access Tokens, или просто перейдите по этой ссылке: https://github.com/settings/tokens.
Там нажмите на кнопку Generate new token
, дайте ему определенное имя, установите срок действия по своему усмотрению, хотя этот токен только считывает данные, поэтому я думаю, что No expiration
вполне подойдет.
Для этого запроса требуются следующие диапазоны:
- [ ] admin:org
- [x] read:org
- [ ] user
- [x] read:user
Затем прокрутите вниз и нажмите на Generate token
.
Убедитесь, что сохранили токен в безопасном и доступном месте, так как вы больше не сможете получить доступ к его значению.
Настройка проекта
Давайте создадим новый проект NextJS. В этом руководстве мы будем использовать TypeScript, поэтому сделаем это с помощью следующей команды:
npx create-next-app --ts {folder}
Замените {folder}
на имя папки, в которой должен находиться проект.
Теперь откройте проект в вашем любимом редакторе или IDE.
Создайте файл .env.local
с ранее сгенерированным Personal Access Token:
GH_PAT=ghp_XXxxXXxxXX
Вы можете назвать переменную по-другому, но будьте внимательны, когда мы будем обращаться к ней в коде позже.
Начальный запрос
Давайте быстро настроим API и код для выполнения начального запроса.
Чтобы все было упорядочено, создадим папку lib
в корне проекта, а внутри нее еще одну папку sponsors
.
Создайте файл с именем request.ts
:
const { GH_PAT: githubPat = '' } = process.env;
const graphQlQuery = `
...
`;
export const getSponsorsGraphQLResponse = async () => {
return fetch('https://api.github.com/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${githubPat}` },
body: JSON.stringify({ query: graphQlQuery }),
}).then((res) => res.json());
};
Поместите содержимое запроса, показанного в начале этого поста, внутри обратных знаков в
graphQlQuery
.
Здесь мы получаем GitHub Personal Access Token (PAT) через переменную окружения GH_PAT
(установленную ранее в .env.local
) и создаем функцию, которая будет выполнять простой fetch POST запрос к https://api.github.com/graphql
, отправляя PAT в заголовке Authorization
, затем получать JSON тело из ответа и возвращать его.
Теперь создайте файл с именем index.ts
:
export * from './request';
Здесь мы просто экспортируем все, что уже экспортировано в файле request.ts
.
Теперь давайте настроим маршрут API. Перейдите в файл pages/api/hello.ts
и переименуйте его в sponsors.ts
, там измените его так, чтобы он выглядел следующим образом:
import type { NextApiRequest, NextApiResponse } from 'next';
import { getSponsorsGraphQLResponse } from './../../lib/sponsors/request';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const rawResponse = await getSponsorsGraphQLResponse();
return res.status(200).json(rawResponse);
}
Здесь мы импортируем ранее созданную функцию, затем вызываем ее с помощью функции async
handler
, затем получаем из нее тело JSON и возвращаем его в качестве ответа API.
Тестирование
Давайте протестируем наш простой API, для этого запустите npm run dev
или yarn dev
в терминале или CMD из корня проекта.
Когда проект будет запущен, вы увидите следующее:
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
Далее, перейдите по адресу http://localhost:3000/api/sponsors
, если все было настроено правильно, вы увидите необработанный ответ от API, формат которого не очень приятен для чтения или использования, и выглядит он примерно так:
{
"data": {
"user": {
"sponsorsListing": {
"id": "MDExxxxxxxxxxxxxxxx==",
"tiers": {
"nodes": [
{
"id": "MDExxxxxxxxxxxxxxxx==",
"adminInfo": {
"sponsorships": {
"totalRecurringMonthlyPriceInDollars": 2,
"nodes": [
{
"sponsorEntity": {
"login": "xxxx",
"avatarUrl": "https://avatars.githubusercontent.com/u/xxxxxx",
"name": "Xxxxx Xxxxx",
"websiteUrl": "https://jahir.dev/"
},
"tierSelectedAt": "2022-03-02T07:59:51Z"
}
]
}
},
"monthlyPriceInDollars": 2,
"isOneTime": false,
"isCustomAmount": false,
"name": "$2 a month",
"description": "Lorem ipsum dolor sit amet."
},
...
]
}
},
"sponsors": {
"totalCount": 1
}
}
}
}
Я рекомендую использовать расширение JSON viewer для более удобного чтения ответа.
Ввод необработанного ответа
Давайте определим интерфейсы для сырого ответа от GitHub GraphQL API. Создайте файл с именем types.ts
в папке lib/sponsors
.
Мы можем начать с самого глубокого вложенного объекта, которым будет sponsorEntity
.
export interface SponsorEntity {
login: string;
name?: string;
avatarUrl: string;
websiteUrl?: string;
}
Теперь мы можем перейти на один уровень вверх к sponsorships
.
interface Sponsorships {
totalRecurringMonthlyPriceInDollars: number;
nodes: Array<{
sponsorEntity: SponsorEntity;
tierSelectedAt?: string; // TimeStamp
}>;
}
Поскольку adminInfo
включает только свойство sponsorships
, давайте поднимемся на пару уровней вверх:
export interface SponsorsTier {
id: string;
adminInfo?: {
sponsorships: Sponsorships;
};
monthlyPriceInDollars: number;
isOneTime: boolean;
isCustomAmount: boolean;
name: string;
description?: string;
}
Теперь перейдем к sponsorsListing
.
interface SponsorsListing {
id: string;
tiers: {
nodes: Array<SponsorsTier>;
};
}
И, наконец, весь ответ:
export interface SponsorsResponse {
data?: {
user: {
sponsorsListing: SponsorsListing;
sponsors: {
totalCount: number;
};
};
};
message?: string;
}
Теперь можно импортировать интерфейс SponsorsResponse
в request.ts
и ввести функцию getSponsorsGraphQLResponse
, так что это будет выглядеть следующим образом:
import type { SponsorsResponse } from './types';
...
export const getSponsorsGraphQLResponse = async (): Promise<SponsorsResponse> => {
...
}
Ввод ответа не повлияет ни на что в API в его нынешнем виде, но позволит нам преобразовать эти данные в более читабельный формат простым способом.
Преобразование необработанного ответа
Сначала давайте спланируем нужный формат объекта, чтобы сделать ответ более удобным для чтения:
{
"tiers": [
{
"id": "MDExxxxxxxxxxxxxxxx==",
"price": 2,
"isOneTime": false,
"isCustomAmount": false,
"name": "$2 a month",
"description": "Lorem ipsum dolor sit amet.",
"totalEarningsPerMonth": 2,
"sponsors": [
{
"username": "xxxx",
"name": "Xxxxx Xxxxx",
"avatar": "https://avatars.githubusercontent.com/u/xxxxxx",
"website": "https://jahir.dev/",
"since": "2022-03-02T07:59:51Z"
},
...
]
},
...
],
"total": 1
}
При таком формате у нас есть объект с 2 свойствами: tiers
и total
. Уровни будут содержать всю соответствующую информацию, включая массив объектов sponsors
с информацией для каждого спонсора. Несколько полей переименованы из необработанного ответа, чтобы сделать их немного проще:
Давайте создадим интерфейсы для этого нового объекта в lib/sponsors/types.ts
:
export interface Sponsor {
username: string;
name?: string;
avatar: string;
website?: string;
since?: string;
}
export interface Tier {
id: string;
price: number;
isOneTime: boolean;
isCustomAmount: boolean;
name: string;
description?: string;
totalEarningsPerMonth: number;
sponsors: Array<Sponsor>;
}
export interface Sponsors {
tiers: Array<Tier>;
total: number;
}
Наконец, давайте создадим функцию для преобразования объекта SponsorsResponse
в объект Sponsors
в файле lib/sponsors/request.ts
:
import type {
SponsorsResponse,
Sponsors,
SponsorEntity,
Sponsor,
SponsorsTier,
Tier,
} from './types';
...
const transformSponsorEntityIntoSponsor = (
entity: SponsorEntity,
tierSelectedAt?: string
): Sponsor => {
return {
name: entity.name,
username: entity.login,
avatar: entity.avatarUrl,
website: entity.websiteUrl,
since: tierSelectedAt,
};
};
const transformRawTierIntoTier = (tier: SponsorsTier): Tier => {
return {
id: tier.id,
price: tier.monthlyPriceInDollars,
isOneTime: tier.isOneTime,
isCustomAmount: tier.isCustomAmount,
name: tier.name,
description: tier.description,
totalEarningsPerMonth: tier.adminInfo?.sponsorships.totalRecurringMonthlyPriceInDollars || 0,
sponsors: (tier.adminInfo?.sponsorships?.nodes || []).map((node) => {
// Transform `SponsorEntity` into `Sponsor` using the `transformSponsorEntityIntoSponsor` function
// and the `tierSelectedAt` property
return transformSponsorEntityIntoSponsor(node.sponsorEntity, node.tierSelectedAt);
}),
};
};
export const transformResponseIntoSponsorships = (rawResponse: SponsorsResponse): Sponsors => {
// Get the listing and sponsors object from the raw response.
// We rename `sponsorsListing` to just `listing` for ease.
// This is done locally only and does not modify the `rawResponse` object.const { sponsorsListing: listing, sponsors } = rawResponse.data?.user || {};
if (!listing || !sponsors) {
return { tiers: [], total: 0 };
}
return {
// Transform `SponsorsTier` into `Tier` using the `transformRawTierIntoTier` function
tiers: listing.tiers.nodes.map(transformRawTierIntoTier),
total: sponsors.totalCount,
};
};
Наконец, давайте обновим наш API, чтобы использовать новую функцию:
import type { NextApiRequest, NextApiResponse } from 'next';
import {
getSponsorsGraphQLResponse,
transformResponseIntoSponsorships,
} from './../../lib/sponsors/request';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const rawResponse = await getSponsorsGraphQLResponse();
return res.status(200).json(transformResponseIntoSponsorships(rawResponse));
}
Теперь, если мы перейдем по адресу http://localhost:3000/api/sponsors
, мы увидим ответ с форматом, который мы изначально планировали. 🎉
Вы можете попробовать развернутую версию этой конечной точки по адресу https://sponsors-edge-api.vercel.app/api/sponsors.
Дополнительно: Edge Runtime
Дополнительно, и это совершенно необязательно, мы можем модифицировать API для использования нового Edge Runtime.
Next.js Edge Runtime основан на стандартных веб-интерфейсах API. Маршруты Edge API позволяют создавать высокопроизводительные API с помощью Next.js. Используя Edge Runtime, они часто работают быстрее, чем API-маршруты на базе Node.js.
Чтобы достичь этого, мы можем модифицировать API таким образом:
- import type { NextApiRequest, NextApiResponse } from 'next';
+ import type { NextRequest } from 'next/server';
import {
getSponsorsGraphQLResponse,
transformResponseIntoSponsorships,
} from './../../lib/sponsors/request';
export const config = {
runtime: 'experimental-edge',
};
- export default async function handler(req: NextApiRequest, res: NextApiResponse) {
+ export default async function handler(req: NextRequest) {
const rawResponse = await getSponsorsGraphQLResponse();
- return res.status(200).json(transformResponseIntoSponsorships(rawResponse));
+ return new Response(JSON.stringify(transformResponseIntoSponsorships(rawResponse)), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
}
Как вы видите, мы меняем тип NextApiRequest
на NextRequest
, удаляем параметр res
из функции обработчика и меняем способ возврата JSON-ответа, используя объект Response
.
Вы можете попробовать развернутую версию этой конечной точки по адресу https://sponsors-edge-api.vercel.app/api/sponsors-edge.
Закрытие
Вот и все, теперь у вас есть API для получения данных о спонсорах GitHub и использования этих данных для чего угодно.
Я, например, использовал его для списка спонсоров на своей странице пожертвований. Надеюсь, это будет полезно и для вас.
Доработанный код проекта вы можете найти по адресу https://github.com/jahirfiquitiva/sponsors-edge-api.
Хорошего дня! 👋😀