Skip to content

Commit

Permalink
refactor: replace ofetch with customFetch and improve error handling
Browse files Browse the repository at this point in the history
- Removed the ofetch dependency from multiple files and replaced it with a customFetch utility for better error handling and flexibility.
- Updated API calls in login, pull languages, and user actions to use the new fetch method.
- Introduced a new ResponseError interface for improved error management.
- Added a getStoryblokUrl utility function to centralize URL construction based on region.
- Enhanced error handling in API error management to accommodate the new FetchError class.
  • Loading branch information
alvarosabu committed Jan 10, 2025
1 parent 16c91fc commit eb4cdca
Show file tree
Hide file tree
Showing 11 changed files with 171 additions and 49 deletions.
14 changes: 14 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,20 @@
"STUB": "true"
}
},
{
"type": "node",
"request": "launch",
"name": "Debug login by token",
"program": "${workspaceFolder}/dist/index.mjs",
"args": ["login", "--token", "1234567890"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"env": {
"STUB": "true"
}
},
{
"type": "node",
"request": "launch",
Expand Down
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@
"chalk": "^5.3.0",
"commander": "^12.1.0",
"dotenv": "^16.4.5",
"ofetch": "^1.4.0",
"storyblok-js-client": "^6.9.2"
},
"devDependencies": {
Expand Down
23 changes: 12 additions & 11 deletions src/commands/login/actions.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import chalk from 'chalk'
import type { RegionCode } from '../../constants'
import { regionsDomain } from '../../constants'
import { FetchError, ofetch } from 'ofetch'
import { customFetch, FetchError } from '../../utils/fetch'
import { APIError, handleAPIError, maskToken } from '../../utils'
import { getStoryblokUrl } from '../../utils/api-routes'

export const loginWithToken = async (token: string, region: RegionCode) => {
try {
return await ofetch(`https://${regionsDomain[region]}/v1/users/me`, {
const url = getStoryblokUrl(region)
return await customFetch(`${url}/users/me`, {
headers: {
Authorization: token,
},
})
}
catch (error) {
if (error instanceof FetchError) {
const status = error.response?.status
const status = error.response.status

switch (status) {
case 401:
Expand All @@ -24,17 +25,16 @@ export const loginWithToken = async (token: string, region: RegionCode) => {
throw new APIError('network_error', 'login_with_token', error)
}
}
else {
throw new APIError('generic', 'login_with_token', error as Error)
}
throw new APIError('generic', 'login_with_token', error as FetchError)
}
}

export const loginWithEmailAndPassword = async (email: string, password: string, region: RegionCode) => {
try {
return await ofetch(`https://${regionsDomain[region]}/v1/users/login`, {
const url = getStoryblokUrl(region)
return await customFetch(`${url}/users/login`, {
method: 'POST',
body: JSON.stringify({ email, password }),
body: { email, password },
})
}
catch (error) {
Expand All @@ -44,9 +44,10 @@ export const loginWithEmailAndPassword = async (email: string, password: string,

export const loginWithOtp = async (email: string, password: string, otp: string, region: RegionCode) => {
try {
return await ofetch(`https://${regionsDomain[region]}/v1/users/login`, {
const url = getStoryblokUrl(region)
return await customFetch(`${url}/users/login`, {
method: 'POST',
body: JSON.stringify({ email, password, otp_attempt: otp }),
body: { email, password, otp_attempt: otp },
})
}
catch (error) {
Expand Down
27 changes: 11 additions & 16 deletions src/commands/pull-languages/actions.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,33 @@
import { join } from 'node:path'

import { handleAPIError, handleFileSystemError } from '../../utils'
import { ofetch } from 'ofetch'
import { regionsDomain } from '../../constants'
import type { FetchError } from '../../utils/fetch'
import { customFetch } from '../../utils/fetch'
import { resolvePath, saveToFile } from '../../utils/filesystem'
import type { PullLanguagesOptions } from './constants'
import type { RegionCode } from '../../constants'
import type { SpaceInternationalization } from '../../types'
import { getStoryblokUrl } from '../../utils/api-routes'

export interface SpaceInternationalizationOptions {
languages: SpaceLanguage[]
default_lang_name: string
}
export interface SpaceLanguage {
code: string
name: string
}

export const pullLanguages = async (space: string, token: string, region: string): Promise<SpaceInternationalizationOptions | undefined> => {
export const pullLanguages = async (space: string, token: string, region: RegionCode): Promise<SpaceInternationalization | undefined> => {
try {
const response = await ofetch(`https://${regionsDomain[region]}/v1/spaces/${space}`, {
const url = getStoryblokUrl(region)
const response = await customFetch(`${url}/spaces/${space}`, {
headers: {
Authorization: token,
},
})

return {
default_lang_name: response.space.default_lang_name,
languages: response.space.languages,
}
}
catch (error) {
handleAPIError('pull_languages', error as Error)
handleAPIError('pull_languages', error as FetchError)
}
}

export const saveLanguagesToFile = async (space: string, internationalizationOptions: SpaceInternationalizationOptions, options: PullLanguagesOptions) => {
export const saveLanguagesToFile = async (space: string, internationalizationOptions: SpaceInternationalization, options: PullLanguagesOptions) => {
try {
const { filename = 'languages', suffix = space, path } = options
const data = JSON.stringify(internationalizationOptions, null, 2)
Expand Down
16 changes: 8 additions & 8 deletions src/commands/user/actions.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import { FetchError, ofetch } from 'ofetch'
import { regionsDomain } from '../../constants'
import chalk from 'chalk'
import type { RegionCode } from '../../constants'
import { customFetch, FetchError } from '../../utils/fetch'
import { APIError, maskToken } from '../../utils'
import { getStoryblokUrl } from '../../utils/api-routes'

export const getUser = async (token: string, region: string) => {
export const getUser = async (token: string, region: RegionCode) => {
try {
return await ofetch(`https://${regionsDomain[region]}/v1/users/me`, {
const url = getStoryblokUrl(region)
return await customFetch(`${url}/users/me`, {
headers: {
Authorization: token,
},
})
}
catch (error) {
if (error instanceof FetchError) {
const status = error.response?.status
const status = error.response.status

switch (status) {
case 401:
Expand All @@ -23,8 +25,6 @@ export const getUser = async (token: string, region: string) => {
throw new APIError('network_error', 'get_user', error)
}
}
else {
throw new APIError('generic', 'get_user', error as Error)
}
throw new APIError('generic', 'get_user', error as FetchError)
}
}
10 changes: 10 additions & 0 deletions src/types/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Interface representing an HTTP response error
*/
export interface ResponseError extends Error {
response?: {
status: number
statusText: string
data?: any
}
}
15 changes: 15 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,18 @@ export interface CommandOptions {
*/
verbose: boolean
}

/**
* Interface representing a language in Storyblok
*/
export interface Language {
name: string
code: string
fallback_code?: string
ai_translation_code: string | null
}

export interface SpaceInternationalization {
languages: Language[]
default_lang_name: string
}
8 changes: 8 additions & 0 deletions src/utils/api-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { RegionCode } from '../constants'
import { regionsDomain } from '../constants'

const API_VERSION = 'v1'

export const getStoryblokUrl = (region: RegionCode) => {
return `https://${regionsDomain[region]}/${API_VERSION}`
}
11 changes: 4 additions & 7 deletions src/utils/error/api-error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { FetchError } from 'ofetch'
import { FetchError } from '../fetch'

export const API_ACTIONS = {
login: 'login',
Expand All @@ -18,9 +18,9 @@ export const API_ERRORS = {
not_found: 'The requested resource was not found',
} as const

export function handleAPIError(action: keyof typeof API_ACTIONS, error: Error): void {
export function handleAPIError(action: keyof typeof API_ACTIONS, error: unknown): void {
if (error instanceof FetchError) {
const status = error.response?.status
const status = error.response.status

switch (status) {
case 401:
Expand All @@ -33,10 +33,7 @@ export function handleAPIError(action: keyof typeof API_ACTIONS, error: Error):
throw new APIError('network_error', action, error)
}
}
else if (error.message.includes('NetworkError') || error.message.includes('Failed to fetch')) {
throw new APIError('network_error', action, error)
}
throw new APIError('generic', action, error)
throw new APIError('generic', action, error as FetchError)
}

export class APIError extends Error {
Expand Down
13 changes: 7 additions & 6 deletions src/utils/error/error.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { konsola } from '..'
import type { FetchError } from '../fetch'
import { APIError } from './api-error'
import { CommandError } from './command-error'
import { FileSystemError } from './filesystem-error'

export function handleError(error: Error, verbose = false): void {
export function handleError(error: Error | FetchError, verbose = false): void {
// Print the message stack if it exists
if ((error as any).messageStack) {
const messageStack = (error as any).messageStack
if (error instanceof APIError || error instanceof FileSystemError) {
const messageStack = (error).messageStack
messageStack.forEach((message: string, index: number) => {
konsola.error(message, null, {
header: index === 0,
Expand All @@ -19,8 +20,8 @@ export function handleError(error: Error, verbose = false): void {
header: true,
})
}
if (verbose && typeof (error as any).getInfo === 'function') {
const errorDetails = (error as any).getInfo()
if (verbose && (error instanceof APIError || error instanceof FileSystemError)) {
const errorDetails = error.getInfo()
if (error instanceof CommandError) {
konsola.error(`Command Error: ${error.getInfo().message}`, errorDetails)
}
Expand All @@ -31,7 +32,7 @@ export function handleError(error: Error, verbose = false): void {
konsola.error(`File System Error: ${error.getInfo().cause}`, errorDetails)
}
else {
konsola.error(`Unexpected Error: ${error.message}`, errorDetails)
konsola.error(`Unexpected Error: ${error}`, errorDetails)
}
}
else {
Expand Down
82 changes: 82 additions & 0 deletions src/utils/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
export class FetchError extends Error {
response: {
status: number
statusText: string
data?: any
}

constructor(message: string, response: { status: number, statusText: string, data?: any }) {
super(message)
this.name = 'FetchError'
this.response = response
}
}

type FetchOptions = Omit<RequestInit, 'body'> & {
body?: any
}

export async function customFetch<T = any>(url: string, options: FetchOptions = {}): Promise<T> {
try {
const headers = {
'Content-Type': 'application/json',
...options.headers,
}

// Handle JSON body
const fetchOptions: RequestInit = {
...options,
headers,
}

if (options.body) {
if (typeof options.body === 'string') {
try {
JSON.parse(options.body)
fetchOptions.body = options.body
}
catch {
fetchOptions.body = JSON.stringify(options.body)
}
}
else {
fetchOptions.body = JSON.stringify(options.body)
}
}

const response = await fetch(url, fetchOptions)

if (!response.ok) {
let data
try {
data = await response.json()
}
catch {
// Ignore JSON parse errors
}
throw new FetchError(`HTTP error! status: ${response.status}`, {
status: response.status,
statusText: response.statusText,
data,
})
}

// Handle empty responses
const contentType = response.headers.get('content-type')
if (contentType?.includes('application/json')) {
return await response.json()
}
return await response.text() as T
}
catch (error) {
if (error instanceof FetchError) {
throw error
}
// For network errors or other non-HTTP errors, create a FetchError
throw new FetchError(error instanceof Error ? error.message : String(error), {
status: 0,
statusText: 'Network Error',
data: null,
})
}
}

0 comments on commit eb4cdca

Please sign in to comment.