Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
674 changes: 674 additions & 0 deletions packages/api-client/LICENSE

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions packages/api-client/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import config from '@modrinth/tooling-config/eslint/base.mjs'
export default config
13 changes: 13 additions & 0 deletions packages/api-client/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"name": "@modrinth/api-client",
"version": "0.1.0",
"description": "An API client for Modrinth's API for use in nuxt, tauri and plain node/browser environments.",
"main": "./src/index.ts",
"scripts": {
"lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write ."
},
"devDependencies": {
"@modrinth/tooling-config": "workspace:*"
}
}
44 changes: 44 additions & 0 deletions packages/api-client/src/api/modrinth-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { FetchRequestHandler } from '../handlers/fetch-handler'
import { BaseRequestOptions, RequestHandler } from '../types/request'

export namespace Modrinth {
/**
* Base API client providing a platform-agnostic `request` method.
*
* Consumers should extend this class to implement domain-specific modules
* (e.g., Projects, Versions). The underlying transport is delegated to a
* `RequestHandler`, which can be Nuxt, Fetch, or Tauri based, while the
* consumer-facing response shape remains `SimpleAsyncData`.
*/
export abstract class AbstractApiClient {
protected readonly baseUrl: string
protected readonly userAgent: string | undefined
protected readonly requestHandler: RequestHandler
/**
* @param requestHandler Transport implementation. Defaults to `FetchRequestHandler` if omitted.
* @param baseUrl Base URL for API requests. Defaults to `https://api.modrinth.com`.
* @param userAgent Optional user-agent header value forwarded by compatible handlers.
*/
constructor(requestHandler?: RequestHandler, baseUrl?: string, userAgent?: string | undefined) {
this.baseUrl = baseUrl ?? 'https://api.modrinth.com'
this.userAgent = userAgent
this.requestHandler = requestHandler ?? new FetchRequestHandler(this.baseUrl, this.userAgent)
}

/**
* Perform a request via the configured `RequestHandler`.
*
* @typeParam DataT Data payload type
* @typeParam ErrorT Error payload type
* @param path Relative request path (e.g., `/v2/project/{id}`)
* @param options Cross-platform request options; Nuxt-only options will be respected by the Nuxt handler
* @returns A promise that resolves to `SimpleAsyncData<DataT, ErrorT>`
*/
protected request<DataT = any, ErrorT = unknown>(
path: string,
options?: BaseRequestOptions<DataT>,
) {
return this.requestHandler.request<DataT, ErrorT>(path, options)
}
}
}
111 changes: 111 additions & 0 deletions packages/api-client/src/handlers/fetch-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { ref } from '../types/refs'
import { BaseRequestOptions, RequestHandler, SimpleAsyncData } from '../types/request'

/**
* Fetch-based request handler for browser and Node environments.
*
* - Uses the global `fetch` API and returns a `SimpleAsyncData` wrapper with
* `data`, `error`, `status`, `refresh`, `execute`, and `clear`.
* - Applies `options.transform` after a successful JSON parse.
* - Honors `options.immediate` (default `true`) to auto-run the request.
* - Nuxt-only options in `BaseRequestOptions` are accepted but ignored.
*
* @example
* const handler = new FetchRequestHandler('https://api.example.com', 'my-app/1.0')
* const res = await handler.request<User>('/v2/me')
* console.log(res.status.value, res.data.value)
*/
export class FetchRequestHandler implements RequestHandler {
/**
* @param baseUrl Base URL prefix applied to all paths
* @param userAgent Optional user-agent header to send
*/
constructor(
private baseUrl: string,
private userAgent?: string,
) {}

/**
* Perform a fetch request and expose an AsyncData-like wrapper.
*
* @typeParam DataT Data payload type
* @typeParam ErrorT Error payload type
* @param path Relative path to append to `baseUrl`
* @param options Request options; `method` defaults to `'GET'`, `immediate` defaults to `true`.
* @returns A `SimpleAsyncData` wrapper with helpers to refresh/execute/clear
*/
async request<DataT = any, ErrorT = unknown>(
path: string,
options: BaseRequestOptions<DataT> = {},
): Promise<SimpleAsyncData<DataT, ErrorT>> {
const data = ref<DataT | undefined>(undefined)
const error = ref<ErrorT | undefined>(undefined)
const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle')

const buildUrl = () => {
const url = new URL(`${this.baseUrl}${path}`)
if (options.query) {
for (const [k, v] of Object.entries(options.query)) {
if (v !== undefined && v !== null) url.searchParams.append(k, String(v))
}
}
return url.toString()
}

const doFetch = async () => {
status.value = 'pending'
error.value = undefined
try {
const res = await fetch(buildUrl(), {
method: options.method ?? 'GET',
headers: {
'Content-Type': 'application/json',
...(this.userAgent ? { 'User-Agent': this.userAgent } : {}),
...(options.headers ?? {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
})

if (!res.ok) {
const err: any = new Error(`HTTP ${res.status}: ${res.statusText}`)
;(err.status = res.status), (err.statusText = res.statusText)
throw err
}

let json = (await res.json()) as DataT
if (options.transform) {
json = await options.transform(json)
}
data.value = json
status.value = 'success'
} catch (e: any) {
error.value = e as ErrorT
status.value = 'error'
}
}

const clear = () => {
data.value = options.default ? (options.default() as any) : undefined
error.value = undefined
status.value = 'idle'
}

// immediate by default
if (options.immediate !== false) {
await doFetch()
}

return {
data,
error,
status,
refresh: async () => {
await doFetch()
},
execute: async () => {
await doFetch()
},
clear,
}
}
}
91 changes: 91 additions & 0 deletions packages/api-client/src/handlers/nuxt-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { refGetter } from '../types/refs'
import { BaseRequestOptions, RequestHandler, SimpleAsyncData } from '../types/request'

/**
* Nuxt request handler that wraps `useAsyncData` and `$fetch` at runtime.
*
* - Requires Nuxt 3 runtime globals: `useAsyncData` and `$fetch`.
* - Applies `options.transform` within the handler for cross-env parity.
* - Honors Nuxt options: `server`, `lazy`, `immediate`, `deep`, `dedupe`, `default`, `pick`, `watch`, `getCachedData`.
* - Uses `options.key` or a derived key for de-duplication/caching.
*
* This class avoids compile-time coupling to Nuxt by resolving globals via `globalThis`.
* If the globals are missing, it throws a clear runtime error.
* @remarks Requires running inside a Nuxt context.
*/
export class NuxtRequestHandler implements RequestHandler {
constructor(
private baseUrl: string,
private userAgent?: string,
) {}

/**
* Perform a Nuxt `$fetch` via `useAsyncData` and expose a SimpleAsyncData wrapper.
*
* @typeParam DataT Data payload type
* @typeParam ErrorT Error payload type
* @param path Relative path to append to `baseUrl`
* @param options Nuxt-aware request options. Defaults: `server=true`, `lazy=false`, `immediate=true`, `deep=false`.
* @throws If Nuxt globals are not available at runtime
*/
async request<DataT = any, ErrorT = unknown>(
path: string,
options: BaseRequestOptions<DataT> = {},
): Promise<SimpleAsyncData<DataT, ErrorT>> {
// Resolve globals provided by Nuxt ($fetch, useAsyncData)
const g: any = globalThis as any
const useAsyncData = g.useAsyncData as
| (<T>(key: string, handler: () => Promise<T>, opts?: any) => any)
| undefined
const $fetch = g.$fetch as (<T>(input: any, init?: any) => Promise<T>) | undefined

if (!useAsyncData || !$fetch) {
throw new Error(
'NuxtRequestHandler requires Nuxt globals (useAsyncData, $fetch) to be available',
)
}

const key = options.key ?? `${path}:${JSON.stringify(options.query ?? {})}`

const nuxtAsync = await useAsyncData<DataT>(
key,
async () => {
const result = await $fetch<DataT>(`${this.baseUrl}${path}`, {
method: options.method ?? 'GET',
headers: {
...(this.userAgent ? { 'User-Agent': this.userAgent } : {}),
...(options.headers ?? {}),
},
body: options.body,
query: options.query,
})
if (options.transform) return await options.transform(result)
return result
},
{
server: options.server ?? true,
lazy: options.lazy ?? false,
immediate: options.immediate ?? true,
deep: options.deep ?? false,
dedupe: options.dedupe,
default: options.default,
pick: options.pick,
watch: options.watch,
getCachedData: options.getCachedData,
transform: undefined, // we already apply transform inside handler for cross-env parity
},
)

// Wrap Nuxt refs into SimpleAsyncData-compatible interface
const simple: SimpleAsyncData<DataT, ErrorT> = {
data: refGetter(() => nuxtAsync.data.value),
error: refGetter(() => nuxtAsync.error.value),
status: refGetter(() => nuxtAsync.status.value),
refresh: nuxtAsync.refresh,
execute: nuxtAsync.execute,
clear: nuxtAsync.clear,
}

return simple
}
}
87 changes: 87 additions & 0 deletions packages/api-client/src/handlers/tauri-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { ref } from '../types/refs'
import { BaseRequestOptions, RequestHandler, SimpleAsyncData } from '../types/request'

/**
* Tauri request handler using the Tauri global `window.__TAURI__.invoke`.
*
* - Avoids compile-time dependency on `@tauri-apps/api` by reading the global.
* - Applies `options.transform` after a successful invocation.
* - Honors `options.immediate` (default `true`).
* - Accepts Nuxt options but ignores them.
*
* @experimental API surface may change as we iterate on Tauri integration.
* @remarks Requires running inside a Tauri WebView context.
*/
export class TauriRequestHandler implements RequestHandler {
/**
* @param command The backend command name to invoke via Tauri
* @default 'api_request'
*/
constructor(private command: string = 'api_request') {}

/**
* Perform a Tauri `invoke` and expose results via SimpleAsyncData.
*
* @typeParam DataT Data payload type
* @typeParam ErrorT Error payload type
* @param path Logical path forwarded to the backend (your command decides how to use it)
* @param options Request options (method, headers, body, query, transform, immediate)
* @throws If Tauri runtime (`window.__TAURI__.invoke`) is not available
*/
async request<DataT = any, ErrorT = unknown>(
path: string,
options: BaseRequestOptions<DataT> = {},
): Promise<SimpleAsyncData<DataT, ErrorT>> {
const data = ref<DataT | undefined>(undefined)
const error = ref<ErrorT | undefined>(undefined)
const status = ref<'idle' | 'pending' | 'success' | 'error'>('idle')

// Resolve invoke from Tauri global to avoid compile-time deps
const tauriGlobal = (globalThis as any).__TAURI__
const invoke: (<T>(cmd: string, args?: any) => Promise<T>) | undefined = tauriGlobal?.invoke
if (!invoke) {
throw new Error('TauriRequestHandler requires Tauri runtime (window.__TAURI__.invoke)')
}

const doInvoke = async () => {
status.value = 'pending'
error.value = undefined
try {
const result = await invoke!<DataT>(this.command, {
path,
method: options.method ?? 'GET',
headers: options.headers,
body: options.body,
query: options.query,
})
const transformed = options.transform ? await options.transform(result) : result
data.value = transformed
status.value = 'success'
} catch (e: any) {
error.value = e as ErrorT
status.value = 'error'
}
}

if (options.immediate !== false) {
await doInvoke()
}

return {
data,
error,
status,
refresh: async () => {
await doInvoke()
},
execute: async () => {
await doInvoke()
},
clear: () => {
data.value = options.default ? (options.default() as any) : undefined
error.value = undefined
status.value = 'idle'
},
}
}
}
5 changes: 5 additions & 0 deletions packages/api-client/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from './api/modrinth-client'
export * from './handlers/fetch-handler'
export * from './handlers/nuxt-handler'
export * from './handlers/tauri-handler'
export * from './types/request'
26 changes: 26 additions & 0 deletions packages/api-client/src/types/refs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Minimal ref-like structure to model both Vue refs and plain values uniformly.
*
* In Nuxt/Vue environments, handlers will return wrappers around real refs.
* In non-Vue environments, this is a simple `{ value }` object.
*
* @template T Value type stored in the ref
*/
export interface SimpleRef<T> {
value: T
}

export function refGetter<T>(get: () => T): SimpleRef<T> {
return {
get value() {
return get()
},
set value(_: T) {
// no-op; SimpleRef here is read-only proxy to Nuxt refs
},
} as unknown as SimpleRef<T>
}

export function ref<T>(value: T): SimpleRef<T> {
return { value }
}
Loading
Loading