import axios, { AxiosError } from 'axios';
import MediumAuth from './auth';
interface PublishArticleParams {
title: string;
content: string;
tags?: string[];
publicationId?: string;
publishStatus?: 'public' | 'draft' | 'unlisted';
notifyFollowers?: boolean;
}
interface UpdateArticleParams {
articleId: string;
title?: string;
content?: string;
tags?: string[];
publishStatus?: 'public' | 'draft' | 'unlisted';
}
interface SearchArticlesParams {
keywords?: string[];
publicationId?: string;
tags?: string[];
}
interface RetryConfig {
maxRetries: number;
baseDelay: number;
maxDelay: number;
}
class MediumClient {
private auth: MediumAuth;
private baseUrl = 'https://api.medium.com/v1';
private retryConfig: RetryConfig = {
maxRetries: 3,
baseDelay: 1000,
maxDelay: 10000
};
private rateLimitRemaining: number | null = null;
private rateLimitReset: number | null = null;
constructor(auth: MediumAuth) {
this.auth = auth;
}
private async sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
private calculateBackoffDelay(attempt: number): number {
const delay = this.retryConfig.baseDelay * Math.pow(2, attempt);
return Math.min(delay, this.retryConfig.maxDelay);
}
private isRetryableError(error: AxiosError): boolean {
if (!error.response) return true; // Network errors are retryable
const status = error.response.status;
// Retry on rate limits, server errors, and some client errors
return status === 429 || status === 503 || status === 502 || status === 504;
}
private updateRateLimitInfo(headers: any): void {
if (headers['x-ratelimit-remaining']) {
this.rateLimitRemaining = parseInt(headers['x-ratelimit-remaining']);
}
if (headers['x-ratelimit-reset']) {
this.rateLimitReset = parseInt(headers['x-ratelimit-reset']) * 1000;
}
}
private async checkRateLimit(): Promise<void> {
if (this.rateLimitRemaining !== null && this.rateLimitRemaining === 0) {
if (this.rateLimitReset) {
const now = Date.now();
const waitTime = this.rateLimitReset - now;
if (waitTime > 0) {
console.log(`⏳ Rate limit reached. Waiting ${Math.ceil(waitTime / 1000)}s...`);
await this.sleep(waitTime);
}
}
}
}
private async makeRequest(
method: 'get' | 'post' | 'put' | 'delete',
endpoint: string,
data?: any,
retryAttempt: number = 0
): Promise<any> {
try {
// Check rate limit before making request
await this.checkRateLimit();
const response = await axios({
method,
url: `${this.baseUrl}${endpoint}`,
headers: {
'Authorization': `Bearer ${this.auth.getAccessToken()}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
data,
validateStatus: (status) => status < 500 // Don't throw on 4xx errors
});
// Update rate limit info
this.updateRateLimitInfo(response.headers);
// Handle non-2xx responses
if (response.status >= 400) {
throw this.createDetailedError(response);
}
return response.data;
} catch (error: any) {
// Enhanced error logging
const axiosError = error as AxiosError;
if (axiosError.response) {
console.error('Medium API Error:', {
status: axiosError.response.status,
statusText: axiosError.response.statusText,
data: axiosError.response.data,
endpoint
});
} else {
console.error('Network Error:', error.message);
}
// Retry logic with exponential backoff
if (this.isRetryableError(axiosError) && retryAttempt < this.retryConfig.maxRetries) {
const delay = this.calculateBackoffDelay(retryAttempt);
console.log(`🔄 Retrying request (attempt ${retryAttempt + 1}/${this.retryConfig.maxRetries}) in ${delay}ms...`);
await this.sleep(delay);
return this.makeRequest(method, endpoint, data, retryAttempt + 1);
}
throw error;
}
}
private createDetailedError(response: any): Error {
const status = response.status;
const data = response.data;
let message = `Medium API Error (${status})`;
if (data?.errors) {
message += `: ${JSON.stringify(data.errors)}`;
} else if (data?.message) {
message += `: ${data.message}`;
} else if (response.statusText) {
message += `: ${response.statusText}`;
}
const error: any = new Error(message);
error.status = status;
error.data = data;
return error;
}
async publishArticle(params: PublishArticleParams) {
// First get user ID
const user = await this.getUserProfile();
const userId = user.data?.id;
if (!userId) {
throw new Error('Failed to retrieve user ID');
}
const payload: any = {
title: params.title,
contentFormat: 'markdown',
content: params.content,
publishStatus: params.publishStatus || 'draft'
};
if (params.tags && params.tags.length > 0) {
payload.tags = params.tags;
}
if (params.notifyFollowers !== undefined) {
payload.notifyFollowers = params.notifyFollowers;
}
const endpoint = params.publicationId
? `/publications/${params.publicationId}/posts`
: `/users/${userId}/posts`;
return this.makeRequest('post', endpoint, payload);
}
async updateArticle(params: UpdateArticleParams) {
const payload: any = {};
if (params.title) payload.title = params.title;
if (params.content) {
payload.content = params.content;
payload.contentFormat = 'markdown';
}
if (params.tags) payload.tags = params.tags;
if (params.publishStatus) payload.publishStatus = params.publishStatus;
return this.makeRequest('put', `/posts/${params.articleId}`, payload);
}
async deleteArticle(articleId: string) {
return this.makeRequest('delete', `/posts/${articleId}`);
}
async getArticle(articleId: string) {
return this.makeRequest('get', `/posts/${articleId}`);
}
async getUserPublications() {
const user = await this.getUserProfile();
const userId = user.data?.id;
if (!userId) {
throw new Error('Failed to retrieve user ID');
}
return this.makeRequest('get', `/users/${userId}/publications`);
}
async searchArticles(params: SearchArticlesParams) {
const queryParams = new URLSearchParams();
if (params.keywords) {
params.keywords.forEach(keyword =>
queryParams.append('q', keyword)
);
}
if (params.publicationId) {
queryParams.append('publicationId', params.publicationId);
}
if (params.tags) {
params.tags.forEach(tag =>
queryParams.append('tag', tag)
);
}
const query = queryParams.toString();
const endpoint = query ? `/articles?${query}` : '/articles';
return this.makeRequest('get', endpoint);
}
async getDrafts() {
const user = await this.getUserProfile();
const userId = user.data?.id;
if (!userId) {
throw new Error('Failed to retrieve user ID');
}
return this.makeRequest('get', `/users/${userId}/posts?status=draft`);
}
async getUserProfile() {
return this.makeRequest('get', '/me');
}
async createDraft(params: {
title: string,
content: string,
tags?: string[]
}) {
const user = await this.getUserProfile();
const userId = user.data?.id;
if (!userId) {
throw new Error('Failed to retrieve user ID');
}
return this.makeRequest('post', `/users/${userId}/posts`, {
title: params.title,
contentFormat: 'markdown',
content: params.content,
tags: params.tags,
publishStatus: 'draft'
});
}
async getPublicationContributors(publicationId: string) {
return this.makeRequest('get', `/publications/${publicationId}/contributors`);
}
async getPublicationPosts(publicationId: string) {
return this.makeRequest('get', `/publications/${publicationId}/posts`);
}
// Utility methods
getRateLimitInfo() {
return {
remaining: this.rateLimitRemaining,
resetAt: this.rateLimitReset ? new Date(this.rateLimitReset) : null
};
}
}
export default MediumClient;