Skip to content

Commit

Permalink
feat: simplified fetch wrapper and enhance type safety
Browse files Browse the repository at this point in the history
- Updated `NetrcMachine` and `SessionState` interfaces to use `RegionCode` for improved type safety.
- Refactored login actions to handle optional chaining for better error handling.
- Enhanced API calls to utilize the new `customFetch` utility with improved type definitions.
- Added tests for `customFetch` to ensure robust error handling and response management.
  • Loading branch information
alvarosabu committed Jan 13, 2025
1 parent 17f4c54 commit a770c11
Show file tree
Hide file tree
Showing 13 changed files with 238 additions and 47 deletions.
14 changes: 14 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,20 @@
"env": {
"STUB": "true"
}
},
{
"type": "node",
"request": "launch",
"name": "Debug Test",
"program": "${workspaceFolder}/dist/index.mjs",
"args": ["test"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",
"sourceMaps": true,
"outFiles": ["${workspaceFolder}/dist/**/*.js"],
"env": {
"STUB": "true"
}
}
]
}
9 changes: 6 additions & 3 deletions src/commands/login/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import type { RegionCode } from '../../constants'
import { customFetch, FetchError } from '../../utils/fetch'
import { APIError, handleAPIError, maskToken } from '../../utils'
import { getStoryblokUrl } from '../../utils/api-routes'
import type { StoryblokLoginResponse, StoryblokLoginWithOtpResponse, StoryblokUser } from '../../types'

export const loginWithToken = async (token: string, region: RegionCode) => {
try {
const url = getStoryblokUrl(region)
return await customFetch(`${url}/users/me`, {
return await customFetch<{
user: StoryblokUser
}>(`${url}/users/me`, {
headers: {
Authorization: token,
},
Expand All @@ -32,7 +35,7 @@ export const loginWithToken = async (token: string, region: RegionCode) => {
export const loginWithEmailAndPassword = async (email: string, password: string, region: RegionCode) => {
try {
const url = getStoryblokUrl(region)
return await customFetch(`${url}/users/login`, {
return await customFetch<StoryblokLoginResponse>(`${url}/users/login`, {
method: 'POST',
body: { email, password },
})
Expand All @@ -45,7 +48,7 @@ export const loginWithEmailAndPassword = async (email: string, password: string,
export const loginWithOtp = async (email: string, password: string, otp: string, region: RegionCode) => {
try {
const url = getStoryblokUrl(region)
return await customFetch(`${url}/users/login`, {
return await customFetch<StoryblokLoginWithOtpResponse>(`${url}/users/login`, {
method: 'POST',
body: { email, password, otp_attempt: otp },
})
Expand Down
10 changes: 6 additions & 4 deletions src/commands/login/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,16 +112,18 @@ export const loginCommand = program
})
const response = await loginWithEmailAndPassword(userEmail, userPassword, userRegion)

if (response.otp_required) {
if (response?.otp_required) {
const otp = await input({
message: 'Add the code from your Authenticator app, or the one we sent to your e-mail / phone:',
required: true,
})

const { access_token } = await loginWithOtp(userEmail, userPassword, otp, userRegion)
updateSession(userEmail, access_token, userRegion)
const otpResponse = await loginWithOtp(userEmail, userPassword, otp, userRegion)
if (otpResponse?.access_token) {
updateSession(userEmail, otpResponse?.access_token, userRegion)
}
}
else {
else if (response?.access_token) {
updateSession(userEmail, response.access_token, userRegion)
}
await persistCredentials(regionsDomain[userRegion])
Expand Down
4 changes: 3 additions & 1 deletion src/commands/pull-languages/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import { getStoryblokUrl } from '../../utils/api-routes'
export const pullLanguages = async (space: string, token: string, region: RegionCode): Promise<SpaceInternationalization | undefined> => {
try {
const url = getStoryblokUrl(region)
const response = await customFetch(`${url}/spaces/${space}`, {
const response = await customFetch<{
space: SpaceInternationalization
}>(`${url}/spaces/${space}`, {
headers: {
Authorization: token,
},
Expand Down
6 changes: 5 additions & 1 deletion src/commands/user/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@ import type { RegionCode } from '../../constants'
import { customFetch, FetchError } from '../../utils/fetch'
import { APIError, maskToken } from '../../utils'
import { getStoryblokUrl } from '../../utils/api-routes'
import type { StoryblokUser } from '../../types'

export const getUser = async (token: string, region: RegionCode) => {
try {
const url = getStoryblokUrl(region)
return await customFetch(`${url}/users/me`, {
const response = await customFetch<{
user: StoryblokUser
}>(`${url}/users/me`, {
headers: {
Authorization: token,
},
})
return response
}
catch (error) {
if (error instanceof FetchError) {
Expand Down
1 change: 1 addition & 0 deletions src/commands/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const userCommand = program
try {
const { password, region } = state as NetrcMachine
const { user } = await getUser(password, region)
console.log(user)
konsola.ok(`Hi ${chalk.bold(user.friendly_name)}, you are currently logged in with ${chalk.hex(colorPalette.PRIMARY)(user.email)} on ${chalk.bold(region)} region`)
}
catch (error) {
Expand Down
3 changes: 2 additions & 1 deletion src/creds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import { join } from 'node:path'
import { FileSystemError, handleFileSystemError, konsola } from './utils'
import chalk from 'chalk'
import { colorPalette, regionCodes } from './constants'
import type { RegionCode } from './constants'

export interface NetrcMachine {
login: string
password: string
region: string
region: RegionCode
}

export const getNetrcFilePath = () => {
Expand Down
20 changes: 17 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import './commands/logout'
import './commands/user'
import './commands/pull-languages'

import { loginWithToken } from './commands/login/actions'
import { customFetch } from './utils/fetch'
import { getStoryblokUrl } from './utils/api-routes'
import { session } from './session'

dotenv.config() // This will load variables from .env into process.env
const program = getProgram()
Expand All @@ -31,8 +33,20 @@ program.command('test').action(async () => {
konsola.title(`Test`, '#8556D3', 'Attempting a test...')
const verbose = program.opts().verbose
try {
// await loginWithEmailAndPassword('aw', 'passwrod', 'eu')
await loginWithToken('WYSYDHYASDHSYD', 'eu')
const { state, initializeSession } = session()
await initializeSession()
const url = getStoryblokUrl()

if (!state.password) {
throw new Error('No password found')
}

const response = await customFetch(`${url}/spaces/2950170505/components`, {
headers: {
Authorization: state.password,
},
})
console.log(response)
}
catch (error) {
handleError(error as Error, verbose)
Expand Down
6 changes: 3 additions & 3 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ interface SessionState {
isLoggedIn: boolean
login?: string
password?: string
region?: string
region?: RegionCode
envLogin?: boolean
}

Expand All @@ -24,7 +24,7 @@ function createSession() {
state.isLoggedIn = true
state.login = envCredentials.login
state.password = envCredentials.password
state.region = envCredentials.region
state.region = envCredentials.region as RegionCode
state.envLogin = true
return
}
Expand All @@ -36,7 +36,7 @@ function createSession() {
state.isLoggedIn = true
state.login = creds.login
state.password = creds.password
state.region = creds.region
state.region = creds.region as RegionCode
}
else {
// No credentials found; set state to logged out
Expand Down
27 changes: 27 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export interface CommandOptions {
verbose: boolean
}

// All these types should come from a general package

/**
* Interface representing a language in Storyblok
*/
Expand All @@ -22,3 +24,28 @@ export interface SpaceInternationalization {
languages: Language[]
default_lang_name: string
}

export interface StoryblokUser {
id: number
email: string
username: string
friendly_name: string
otp_required: boolean
access_token: string
}

export interface StoryblokLoginResponse {
otp_required: boolean
login_strategy: string
configured_2fa_options: string[]
access_token?: string
}

export interface StoryblokLoginWithOtpResponse {
access_token: string
email: string
token_type: string
user_id: number
role: string
has_partner: boolean
}
2 changes: 1 addition & 1 deletion src/utils/api-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ import { regionsDomain } from '../constants'

const API_VERSION = 'v1'

export const getStoryblokUrl = (region: RegionCode) => {
export const getStoryblokUrl = (region: RegionCode = 'eu') => {
return `https://${regionsDomain[region]}/${API_VERSION}`
}
129 changes: 129 additions & 0 deletions src/utils/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, expect, it, vi } from 'vitest'
import { customFetch, FetchError } from './fetch'

// Mock fetch
const mockFetch = vi.fn()
globalThis.fetch = mockFetch

describe('customFetch', () => {
it('should make a successful GET request', async () => {
const mockResponse = { data: 'test' }
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: () => Promise.resolve(mockResponse),
})

const result = await customFetch('https://api.test.com')
expect(result).toEqual(mockResponse)
})

it('should handle object body by stringifying it', async () => {
const body = { test: 'data' }
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: () => Promise.resolve({}),
})

await customFetch('https://api.test.com', { body })

expect(mockFetch).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({
body: JSON.stringify(body),
}))
})

it('should pass string body as-is without modification', async () => {
const body = '{"test":"data"}'
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: () => Promise.resolve({}),
})

await customFetch('https://api.test.com', { body })

expect(mockFetch).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({
body,
}))
})

it('should handle array body by stringifying it', async () => {
const body = ['test', 'data']
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: () => Promise.resolve({}),
})

await customFetch('https://api.test.com', { body })

expect(mockFetch).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({
body: JSON.stringify(body),
}))
})

it('should handle non-JSON responses', async () => {
const textResponse = 'Hello World'
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'text/plain' },
text: () => Promise.resolve(textResponse),
})

await expect(customFetch('https://api.test.com')).rejects.toThrow(FetchError)
})

it('should throw FetchError for HTTP errors', async () => {
const errorResponse = { message: 'Not Found' }
mockFetch.mockResolvedValue({
ok: false,
status: 404,
statusText: 'Not Found',
json: () => Promise.resolve(errorResponse),
})

await expect(customFetch('https://api.test.com')).rejects.toThrow(FetchError)
await expect(customFetch('https://api.test.com')).rejects.toMatchObject({
response: {
status: 404,
statusText: 'Not Found',
data: errorResponse,
},
})
})

it('should handle network errors', async () => {
mockFetch.mockRejectedValue(new Error('Network Error'))

await expect(customFetch('https://api.test.com')).rejects.toThrow(FetchError)
await expect(customFetch('https://api.test.com')).rejects.toMatchObject({
response: {
status: 0,
statusText: 'Network Error',
data: null,
},
})
})

it('should set correct headers', async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
headers: { get: () => 'application/json' },
json: () => Promise.resolve({}),
})

await customFetch('https://api.test.com', {
headers: {
Authorization: 'Bearer token',
},
})

expect(mockFetch).toHaveBeenCalledWith('https://api.test.com', expect.objectContaining({
headers: expect.objectContaining({
'Content-Type': 'application/json',
'Authorization': 'Bearer token',
}),
}))
})
})
Loading

0 comments on commit a770c11

Please sign in to comment.