Skip to content

Commit 79705e7

Browse files
authored
feat: add valibot & implement validation/error handling for APIs (#84)
1 parent aa418a5 commit 79705e7

15 files changed

Lines changed: 365 additions & 176 deletions

File tree

CONTRIBUTING.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,59 @@ The connector will check your npm authentication, generate a connection token, a
110110
- We care about good types – never cast things to `any` 💪
111111
- Validate rather than just assert
112112

113+
### Server API patterns
114+
115+
#### Input validation with Valibot
116+
117+
Use Valibot schemas from `#shared/schemas/` to validate API inputs. This ensures type safety and provides consistent error messages:
118+
119+
```typescript
120+
import * as v from 'valibot'
121+
import { PackageRouteParamsSchema } from '#shared/schemas/package'
122+
123+
// In your handler:
124+
const { packageName, version } = v.parse(PackageRouteParamsSchema, {
125+
packageName: rawPackageName,
126+
version: rawVersion,
127+
})
128+
```
129+
130+
#### Error handling with `handleApiError`
131+
132+
Use the `handleApiError` utility for consistent error handling in API routes. It re-throws H3 errors (like 404s) and wraps other errors with a fallback message:
133+
134+
```typescript
135+
import { ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants'
136+
137+
try {
138+
// API logic...
139+
} catch (error: unknown) {
140+
handleApiError(error, {
141+
statusCode: 502,
142+
message: ERROR_NPM_FETCH_FAILED,
143+
})
144+
}
145+
```
146+
147+
#### URL parameter parsing with `parsePackageParams`
148+
149+
Use `parsePackageParams` to extract package name and version from URL segments:
150+
151+
```typescript
152+
const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
153+
const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)
154+
```
155+
156+
This handles patterns like `/pkg`, `/pkg/v/1.0.0`, `/@scope/pkg`, and `/@scope/pkg/v/1.0.0`.
157+
158+
#### Constants
159+
160+
Define error messages and other string constants in `#shared/utils/constants.ts` to ensure consistency across the codebase:
161+
162+
```typescript
163+
export const ERROR_NPM_FETCH_FAILED = 'Failed to fetch package from npm registry.'
164+
```
165+
113166
### Import order
114167

115168
1. Type imports first (`import type { ... }`)

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"shiki": "^3.21.0",
4545
"ufo": "^1.6.3",
4646
"unplugin-vue-router": "^0.19.2",
47+
"valibot": "^1.2.0",
4748
"validate-npm-package-name": "^7.0.2",
4849
"virtua": "^0.48.3",
4950
"vue": "3.5.27",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/api/jsr/[...pkg].get.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import * as v from 'valibot'
2+
import { PackageNameSchema } from '#shared/schemas/package'
3+
import { CACHE_MAX_AGE_ONE_HOUR, ERROR_JSR_FETCH_FAILED } from '#shared/utils/constants'
14
import type { JsrPackageInfo } from '#shared/types/jsr'
25

36
/**
@@ -11,17 +14,25 @@ import type { JsrPackageInfo } from '#shared/types/jsr'
1114
export default defineCachedEventHandler<Promise<JsrPackageInfo>>(
1215
async event => {
1316
const pkgPath = getRouterParam(event, 'pkg')
14-
if (!pkgPath) {
15-
throw createError({ statusCode: 400, message: 'Package name is required' })
16-
}
17-
assertValidPackageName(pkgPath)
1817

19-
return await fetchJsrPackageInfo(pkgPath)
18+
try {
19+
const packageName = v.parse(PackageNameSchema, pkgPath)
20+
21+
return await fetchJsrPackageInfo(packageName)
22+
} catch (error: unknown) {
23+
handleApiError(error, {
24+
statusCode: 502,
25+
message: ERROR_JSR_FETCH_FAILED,
26+
})
27+
}
2028
},
2129
{
22-
maxAge: 60 * 60, // 1 hour
30+
maxAge: CACHE_MAX_AGE_ONE_HOUR,
2331
swr: true,
2432
name: 'api-jsr-package',
25-
getKey: event => getRouterParam(event, 'pkg') ?? '',
33+
getKey: event => {
34+
const pkg = getRouterParam(event, 'pkg') ?? ''
35+
return `jsr:v1:${pkg.replace(/\/+$/, '').trim()}`
36+
},
2637
},
2738
)
Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,28 @@
1+
import * as v from 'valibot'
2+
import { PackageNameSchema } from '#shared/schemas/package'
3+
import { CACHE_MAX_AGE_ONE_HOUR, ERROR_NPM_FETCH_FAILED } from '#shared/utils/constants'
4+
15
export default defineCachedEventHandler(
26
async event => {
3-
const pkg = getRouterParam(event, 'pkg')
4-
if (!pkg) {
5-
throw createError({ statusCode: 400, message: 'Package name is required' })
6-
}
7+
try {
8+
const pkg = getRouterParam(event, 'pkg')
79

8-
assertValidPackageName(pkg)
10+
const packageName = v.parse(PackageNameSchema, pkg)
911

10-
try {
11-
return await fetchNpmPackage(pkg)
12-
} catch (error) {
13-
if (error && typeof error === 'object' && 'statusCode' in error) {
14-
throw error
15-
}
16-
throw createError({ statusCode: 502, message: 'Failed to fetch package from npm registry' })
12+
return await fetchNpmPackage(packageName)
13+
} catch (error: unknown) {
14+
handleApiError(error, {
15+
statusCode: 502,
16+
message: ERROR_NPM_FETCH_FAILED,
17+
})
1718
}
1819
},
1920
{
20-
maxAge: 60 * 60, // 1 hour
21+
maxAge: CACHE_MAX_AGE_ONE_HOUR,
2122
swr: true,
22-
getKey: event => getRouterParam(event, 'pkg') ?? '',
23+
getKey: event => {
24+
const pkg = getRouterParam(event, 'pkg') ?? ''
25+
return `packument:v1:${pkg.replace(/\/+$/, '').trim()}`
26+
},
2327
},
2428
)

server/api/registry/analysis/[...pkg].get.ts

Lines changed: 24 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,31 @@
1+
import * as v from 'valibot'
2+
import { PackageRouteParamsSchema } from '#shared/schemas/package'
13
import type { PackageAnalysis, ExtendedPackageJson } from '#shared/utils/package-analysis'
24
import {
35
analyzePackage,
46
getTypesPackageName,
57
hasBuiltInTypes,
68
} from '#shared/utils/package-analysis'
7-
8-
const NPM_REGISTRY = 'https://registry.npmjs.org'
9+
import {
10+
NPM_REGISTRY,
11+
CACHE_MAX_AGE_ONE_DAY,
12+
ERROR_PACKAGE_ANALYSIS_FAILED,
13+
} from '#shared/utils/constants'
914

1015
export default defineCachedEventHandler(
1116
async event => {
12-
const pkgParam = getRouterParam(event, 'pkg')
13-
if (!pkgParam) {
14-
throw createError({ statusCode: 400, message: 'Package name is required' })
15-
}
16-
1717
// Parse package name and optional version from path
1818
// e.g., "vue" or "vue/v/3.4.0" or "@nuxt/kit" or "@nuxt/kit/v/1.0.0"
19-
const segments = pkgParam.split('/')
20-
let packageName: string
21-
let version: string | undefined
19+
const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
2220

23-
const vIndex = segments.indexOf('v')
24-
if (vIndex !== -1 && vIndex < segments.length - 1) {
25-
packageName = segments.slice(0, vIndex).join('/')
26-
version = segments.slice(vIndex + 1).join('/')
27-
} else {
28-
packageName = segments.join('/')
29-
}
21+
const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments)
3022

3123
try {
24+
const { packageName, version } = v.parse(PackageRouteParamsSchema, {
25+
packageName: rawPackageName,
26+
version: rawVersion,
27+
})
28+
3229
// Fetch package data
3330
const encodedName = encodePackageName(packageName)
3431
const versionSuffix = version ? `/${version}` : '/latest'
@@ -39,8 +36,8 @@ export default defineCachedEventHandler(
3936
// Only check for @types package if the package doesn't ship its own types
4037
let typesPackageExists = false
4138
if (!hasBuiltInTypes(pkg)) {
42-
const typesPackageName = getTypesPackageName(packageName)
43-
typesPackageExists = await checkPackageExists(typesPackageName)
39+
const typesPkgName = getTypesPackageName(packageName)
40+
typesPackageExists = await checkPackageExists(typesPkgName)
4441
}
4542

4643
const analysis = analyzePackage(pkg, { typesPackageExists })
@@ -50,20 +47,20 @@ export default defineCachedEventHandler(
5047
version: pkg.version ?? version ?? 'latest',
5148
...analysis,
5249
} satisfies PackageAnalysisResponse
53-
} catch (error) {
54-
if (error && typeof error === 'object' && 'statusCode' in error) {
55-
throw error
56-
}
57-
throw createError({
50+
} catch (error: unknown) {
51+
handleApiError(error, {
5852
statusCode: 502,
59-
message: 'Failed to analyze package',
53+
message: ERROR_PACKAGE_ANALYSIS_FAILED,
6054
})
6155
}
6256
},
6357
{
64-
maxAge: 60 * 60 * 24, // 24 hours - analysis rarely changes
58+
maxAge: CACHE_MAX_AGE_ONE_DAY, // 24 hours - analysis rarely changes
6559
swr: true,
66-
getKey: event => getRouterParam(event, 'pkg') ?? '',
60+
getKey: event => {
61+
const pkg = getRouterParam(event, 'pkg') ?? ''
62+
return `analysis:v1:${pkg.replace(/\/+$/, '').trim()}`
63+
},
6764
},
6865
)
6966

server/api/registry/file/[...pkg].get.ts

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
import * as v from 'valibot'
2+
import { PackageFileQuerySchema } from '#shared/schemas/package'
3+
import {
4+
CACHE_MAX_AGE_ONE_YEAR,
5+
ERROR_PACKAGE_VERSION_AND_FILE_FAILED,
6+
} from '#shared/utils/constants'
7+
18
const CACHE_VERSION = 2
29

310
// Maximum file size to fetch and highlight (500KB)
@@ -50,7 +57,10 @@ async function fetchFileContent(
5057
if (response.status === 404) {
5158
throw createError({ statusCode: 404, message: 'File not found' })
5259
}
53-
throw createError({ statusCode: 502, message: 'Failed to fetch file from jsDelivr' })
60+
throw createError({
61+
statusCode: 502,
62+
message: 'Failed to fetch file from jsDelivr',
63+
})
5464
}
5565

5666
// Check content-length header if available
@@ -84,38 +94,33 @@ async function fetchFileContent(
8494
*/
8595
export default defineCachedEventHandler(
8696
async event => {
87-
const segments = getRouterParam(event, 'pkg')?.split('/') ?? []
88-
if (segments.length === 0) {
89-
throw createError({
90-
statusCode: 400,
91-
message: 'Package name, version, and file path are required',
92-
})
93-
}
94-
9597
// Parse: [pkg, 'v', version, ...filePath] or [@scope, pkg, 'v', version, ...filePath]
96-
const vIndex = segments.indexOf('v')
97-
if (vIndex === -1 || vIndex >= segments.length - 2) {
98-
throw createError({ statusCode: 400, message: 'Version and file path are required' })
99-
}
98+
const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? []
10099

101-
const packageName = segments.slice(0, vIndex).join('/')
102-
// Find where version ends (next segment after 'v') and file path begins
103-
// Version could be like "1.2.3" or "1.2.3-beta.1"
104-
const versionAndPath = segments.slice(vIndex + 1)
100+
const { rawPackageName, rawVersion: fullPathAfterV } = parsePackageParams(pkgParamSegments)
105101

106-
// The version is the first segment after 'v', and everything else is the file path
107-
const version = versionAndPath[0]
108-
const filePath = versionAndPath.slice(1).join('/')
102+
// Since version AND path route are required, we split the remainder
103+
// fullPathAfterV => "1.2.3/dist/index.mjs"
104+
const versionSegments = fullPathAfterV?.split('/') ?? []
109105

110-
if (!packageName || !version || !filePath) {
106+
if (versionSegments.length < 2) {
111107
throw createError({
112108
statusCode: 400,
113-
message: 'Package name, version, and file path are required',
109+
message: ERROR_PACKAGE_VERSION_AND_FILE_FAILED,
114110
})
115111
}
116-
assertValidPackageName(packageName)
112+
113+
// The version is the first segment after 'v', and everything else is the file path
114+
const rawVersion = versionSegments[0]
115+
const rawFilePath = versionSegments.slice(1).join('/')
117116

118117
try {
118+
const { packageName, version, filePath } = v.parse(PackageFileQuerySchema, {
119+
packageName: rawPackageName,
120+
version: rawVersion,
121+
filePath: rawFilePath,
122+
})
123+
119124
const content = await fetchFileContent(packageName, version, filePath)
120125
const language = getLanguageFromPath(filePath)
121126

@@ -156,7 +161,10 @@ export default defineCachedEventHandler(
156161
}
157162
}
158163

159-
const html = await highlightCode(content, language, { dependencies, resolveRelative })
164+
const html = await highlightCode(content, language, {
165+
dependencies,
166+
resolveRelative,
167+
})
160168

161169
return {
162170
package: packageName,
@@ -167,19 +175,19 @@ export default defineCachedEventHandler(
167175
html,
168176
lines: content.split('\n').length,
169177
}
170-
} catch (error) {
171-
if (error && typeof error === 'object' && 'statusCode' in error) {
172-
throw error
173-
}
174-
throw createError({ statusCode: 502, message: 'Failed to fetch file content' })
178+
} catch (error: unknown) {
179+
handleApiError(error, {
180+
statusCode: 502,
181+
message: 'Failed to fetch file content',
182+
})
175183
}
176184
},
177185
{
178186
// File content for a specific version never changes - cache permanently
179-
maxAge: 60 * 60 * 24 * 365, // 1 year
187+
maxAge: CACHE_MAX_AGE_ONE_YEAR, // 1 year
180188
getKey: event => {
181189
const pkg = getRouterParam(event, 'pkg') ?? ''
182-
return `file:v${CACHE_VERSION}:${pkg}`
190+
return `file:v${CACHE_VERSION}:${pkg.replace(/\/+$/, '').trim()}`
183191
},
184192
},
185193
)

0 commit comments

Comments
 (0)