hanni 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.js +103 -0
- package/package.json +26 -0
- package/src/context.js +48 -0
- package/src/hanni.js +78 -0
- package/src/middleware.js +13 -0
- package/src/router.js +35 -0
- package/src/scalar.js +0 -0
- package/src/swagger.js +193 -0
- package/src/utils.js +14 -0
package/index.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Hanni } from './src/hanni.js'
|
|
2
|
+
|
|
3
|
+
const app = Hanni({
|
|
4
|
+
swagger: {
|
|
5
|
+
path: '/docs',
|
|
6
|
+
title: 'My API',
|
|
7
|
+
description:
|
|
8
|
+
'Contoh REST API sederhana untuk manajemen user. Dibangun dengan hanni.js.',
|
|
9
|
+
version: '2.0.0'
|
|
10
|
+
}
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
const users = [
|
|
14
|
+
{ id: '1', name: 'Hanni', age: 21 },
|
|
15
|
+
{ id: '2', name: 'Minji', age: 22 }
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
app.get(
|
|
19
|
+
'/users',
|
|
20
|
+
c => {
|
|
21
|
+
return c.json({
|
|
22
|
+
total: users.length,
|
|
23
|
+
data: users
|
|
24
|
+
})
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
summary: 'Get all users',
|
|
28
|
+
tags: ['Users'],
|
|
29
|
+
response: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
total: { type: 'number' },
|
|
33
|
+
data: {
|
|
34
|
+
type: 'array',
|
|
35
|
+
items: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
id: { type: 'string' },
|
|
39
|
+
name: { type: 'string' },
|
|
40
|
+
age: { type: 'number' }
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
app.post(
|
|
50
|
+
'/users/:id',
|
|
51
|
+
c => {
|
|
52
|
+
const { id } = c.params
|
|
53
|
+
const { verbose } = c.query
|
|
54
|
+
const body = c.body || {}
|
|
55
|
+
|
|
56
|
+
let user = users.find(u => u.id === id)
|
|
57
|
+
|
|
58
|
+
if (!user) {
|
|
59
|
+
user = { id, ...body }
|
|
60
|
+
users.push(user)
|
|
61
|
+
} else {
|
|
62
|
+
Object.assign(user, body)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return c.json({
|
|
66
|
+
success: true,
|
|
67
|
+
verbose: !!verbose,
|
|
68
|
+
user
|
|
69
|
+
})
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
summary: 'Update user',
|
|
73
|
+
description: 'Update user by id',
|
|
74
|
+
tags: ['Users'],
|
|
75
|
+
query: {
|
|
76
|
+
verbose: true
|
|
77
|
+
},
|
|
78
|
+
body: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
properties: {
|
|
81
|
+
name: { type: 'string' },
|
|
82
|
+
age: { type: 'number' }
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
response: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
properties: {
|
|
88
|
+
success: { type: 'boolean' },
|
|
89
|
+
verbose: { type: 'boolean' },
|
|
90
|
+
user: {
|
|
91
|
+
type: 'object',
|
|
92
|
+
properties: {
|
|
93
|
+
id: { type: 'string' },
|
|
94
|
+
name: { type: 'string' },
|
|
95
|
+
age: { type: 'number' }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
app.listen(3000)
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hanni",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Minimalist Bun web framework with built-in Swagger UI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.js",
|
|
12
|
+
"src"
|
|
13
|
+
],
|
|
14
|
+
"keywords": [
|
|
15
|
+
"bun",
|
|
16
|
+
"framework",
|
|
17
|
+
"api",
|
|
18
|
+
"swagger",
|
|
19
|
+
"openapi"
|
|
20
|
+
],
|
|
21
|
+
"author": "Your Name",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"engines": {
|
|
24
|
+
"bun": ">=1.0.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/context.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function createContext(req, params = {}) {
|
|
2
|
+
const url = new URL(req.url)
|
|
3
|
+
const headers = Object.fromEntries(req.headers)
|
|
4
|
+
const cookies = {}
|
|
5
|
+
|
|
6
|
+
if (headers.cookie) {
|
|
7
|
+
headers.cookie.split(';').forEach(c => {
|
|
8
|
+
const [k, v] = c.trim().split('=')
|
|
9
|
+
cookies[k] = decodeURIComponent(v)
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
req,
|
|
15
|
+
method: req.method,
|
|
16
|
+
url: url.pathname,
|
|
17
|
+
query: Object.fromEntries(url.searchParams),
|
|
18
|
+
params,
|
|
19
|
+
headers,
|
|
20
|
+
cookies,
|
|
21
|
+
body: null,
|
|
22
|
+
|
|
23
|
+
text(data, status = 200, extra = {}) {
|
|
24
|
+
return new Response(data, { status, headers: extra })
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
json(data, status = 200, extra = {}) {
|
|
28
|
+
return new Response(JSON.stringify(data), {
|
|
29
|
+
status,
|
|
30
|
+
headers: { 'Content-Type': 'application/json', ...extra }
|
|
31
|
+
})
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
html(data, status = 200, extra = {}) {
|
|
35
|
+
return new Response(data, {
|
|
36
|
+
status,
|
|
37
|
+
headers: { 'Content-Type': 'text/html', ...extra }
|
|
38
|
+
})
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
redirect(loc, status = 302) {
|
|
42
|
+
return new Response(null, {
|
|
43
|
+
status,
|
|
44
|
+
headers: { Location: loc }
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/hanni.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Router } from './router.js'
|
|
2
|
+
import { createContext } from './context.js'
|
|
3
|
+
import { buildSpec, swaggerHTML } from './swagger.js'
|
|
4
|
+
import { compose } from './middleware.js'
|
|
5
|
+
import { parseBody, corsHeaders } from './utils.js'
|
|
6
|
+
|
|
7
|
+
export function Hanni(config = {}) {
|
|
8
|
+
const router = new Router()
|
|
9
|
+
const middlewares = []
|
|
10
|
+
const swaggerCfg = config.swagger
|
|
11
|
+
|
|
12
|
+
const app = {}
|
|
13
|
+
|
|
14
|
+
const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', 'ALL']
|
|
15
|
+
|
|
16
|
+
for (const m of methods) {
|
|
17
|
+
app[m.toLowerCase()] = (path, handler, meta) => {
|
|
18
|
+
router.add(m, path, handler, meta)
|
|
19
|
+
return app
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
app.use = fn => {
|
|
24
|
+
middlewares.push(fn)
|
|
25
|
+
return app
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
app.listen = port => {
|
|
29
|
+
Bun.serve({
|
|
30
|
+
port,
|
|
31
|
+
async fetch(req) {
|
|
32
|
+
const url = new URL(req.url)
|
|
33
|
+
|
|
34
|
+
if (swaggerCfg) {
|
|
35
|
+
const base = swaggerCfg.path
|
|
36
|
+
|
|
37
|
+
if (url.pathname === base || url.pathname === base + '/') {
|
|
38
|
+
return new Response(
|
|
39
|
+
swaggerHTML(base + '/json', swaggerCfg),
|
|
40
|
+
{ headers: { 'Content-Type': 'text/html' } }
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (url.pathname === base + '/json') {
|
|
45
|
+
return Response.json(
|
|
46
|
+
buildSpec(router.list(), swaggerCfg)
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (config.cors && req.method === 'OPTIONS') {
|
|
52
|
+
return new Response(null, { headers: corsHeaders() })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const match = router.match(req.method, url.pathname)
|
|
56
|
+
if (!match) return new Response('Not Found', { status: 404 })
|
|
57
|
+
|
|
58
|
+
const ctx = createContext(req, match.params)
|
|
59
|
+
ctx.body = await parseBody(req)
|
|
60
|
+
|
|
61
|
+
const handler = compose(middlewares, match.handler)
|
|
62
|
+
const res = await handler(ctx)
|
|
63
|
+
|
|
64
|
+
if (config.cors) {
|
|
65
|
+
Object.entries(corsHeaders()).forEach(([k, v]) =>
|
|
66
|
+
res.headers.set(k, v)
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return res
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
console.log(`hanni.js running on ${port}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return app
|
|
78
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function compose(middlewares, handler) {
|
|
2
|
+
return function (ctx) {
|
|
3
|
+
let i = -1
|
|
4
|
+
const dispatch = idx => {
|
|
5
|
+
if (idx <= i) return Promise.reject()
|
|
6
|
+
i = idx
|
|
7
|
+
const fn = idx === middlewares.length ? handler : middlewares[idx]
|
|
8
|
+
if (!fn) return Promise.resolve()
|
|
9
|
+
return Promise.resolve(fn(ctx, () => dispatch(idx + 1)))
|
|
10
|
+
}
|
|
11
|
+
return dispatch(0)
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/router.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export class Router {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.routes = []
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
add(method, path, handler, meta = {}) {
|
|
7
|
+
const keys = []
|
|
8
|
+
const regex = new RegExp(
|
|
9
|
+
'^' +
|
|
10
|
+
path.replace(/\/:([^/]+)/g, (_, k) => {
|
|
11
|
+
keys.push(k)
|
|
12
|
+
return '/([^/]+)'
|
|
13
|
+
}) +
|
|
14
|
+
'$'
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
this.routes.push({ method, path, regex, keys, handler, meta })
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
match(method, pathname) {
|
|
21
|
+
for (const r of this.routes) {
|
|
22
|
+
if (r.method !== method && r.method !== 'ALL') continue
|
|
23
|
+
const m = pathname.match(r.regex)
|
|
24
|
+
if (!m) continue
|
|
25
|
+
|
|
26
|
+
const params = {}
|
|
27
|
+
r.keys.forEach((k, i) => (params[k] = m[i + 1]))
|
|
28
|
+
return { handler: r.handler, params, meta: r.meta }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
list() {
|
|
33
|
+
return this.routes
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/scalar.js
ADDED
|
File without changes
|
package/src/swagger.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
const HTTP_METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']
|
|
2
|
+
|
|
3
|
+
function normalizePath(path) {
|
|
4
|
+
return path.replace(/:([^/]+)/g, '{$1}')
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function extractPathParams(path) {
|
|
8
|
+
const params = []
|
|
9
|
+
path.replace(/:([^/]+)/g, (_, k) => {
|
|
10
|
+
params.push({
|
|
11
|
+
name: k,
|
|
12
|
+
in: 'path',
|
|
13
|
+
required: true,
|
|
14
|
+
schema: { type: 'string' }
|
|
15
|
+
})
|
|
16
|
+
})
|
|
17
|
+
return params
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function extractQueryParams(meta) {
|
|
21
|
+
if (!meta?.query) return []
|
|
22
|
+
return Object.keys(meta.query).map(k => ({
|
|
23
|
+
name: k,
|
|
24
|
+
in: 'query',
|
|
25
|
+
required: false,
|
|
26
|
+
schema: { type: 'string' }
|
|
27
|
+
}))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function buildRequestBody(meta, method) {
|
|
31
|
+
if (!meta?.body) return undefined
|
|
32
|
+
if (method === 'get' || method === 'head') return undefined
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
required: true,
|
|
36
|
+
content: {
|
|
37
|
+
'application/json': {
|
|
38
|
+
schema: meta.body
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function buildSpec(routes, cfg = {}) {
|
|
45
|
+
const paths = {}
|
|
46
|
+
const tagsMap = new Map()
|
|
47
|
+
|
|
48
|
+
for (const r of routes) {
|
|
49
|
+
const openPath = normalizePath(r.path)
|
|
50
|
+
if (!paths[openPath]) paths[openPath] = {}
|
|
51
|
+
|
|
52
|
+
const methods =
|
|
53
|
+
r.method === 'ALL'
|
|
54
|
+
? HTTP_METHODS
|
|
55
|
+
: [r.method.toLowerCase()]
|
|
56
|
+
|
|
57
|
+
const routeTags =
|
|
58
|
+
Array.isArray(r.meta?.tags) && r.meta.tags.length
|
|
59
|
+
? r.meta.tags
|
|
60
|
+
: ['Default']
|
|
61
|
+
|
|
62
|
+
for (const tag of routeTags) {
|
|
63
|
+
if (!tagsMap.has(tag)) {
|
|
64
|
+
tagsMap.set(tag, { name: tag })
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const method of methods) {
|
|
69
|
+
paths[openPath][method] = {
|
|
70
|
+
tags: routeTags,
|
|
71
|
+
summary: r.meta?.summary || '',
|
|
72
|
+
description: r.meta?.description || '',
|
|
73
|
+
parameters: [
|
|
74
|
+
...extractPathParams(r.path),
|
|
75
|
+
...extractQueryParams(r.meta)
|
|
76
|
+
],
|
|
77
|
+
requestBody: buildRequestBody(r.meta, method),
|
|
78
|
+
responses: {
|
|
79
|
+
200: {
|
|
80
|
+
description: 'Success',
|
|
81
|
+
content: {
|
|
82
|
+
'application/json': {
|
|
83
|
+
schema: r.meta?.response || { type: 'object' }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
openapi: '3.0.3',
|
|
94
|
+
info: {
|
|
95
|
+
title: cfg.title || 'API',
|
|
96
|
+
version: cfg.version || '1.0.0',
|
|
97
|
+
description: cfg.description || ''
|
|
98
|
+
},
|
|
99
|
+
tags: Array.from(tagsMap.values()),
|
|
100
|
+
paths
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function swaggerHTML(jsonPath, cfg = {}) {
|
|
105
|
+
const title = cfg.title || 'Hanni Docs'
|
|
106
|
+
const favicon =
|
|
107
|
+
cfg.favicon ||
|
|
108
|
+
`data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'><path d='M12 2L2 7l10 5 10-5-10-5z'/></svg>`
|
|
109
|
+
|
|
110
|
+
return `
|
|
111
|
+
<!doctype html>
|
|
112
|
+
<html lang="en">
|
|
113
|
+
<head>
|
|
114
|
+
<meta charset="utf-8"/>
|
|
115
|
+
<title>${title}</title>
|
|
116
|
+
|
|
117
|
+
<link rel="icon" href="${favicon}">
|
|
118
|
+
|
|
119
|
+
<!-- Tailwind CDN -->
|
|
120
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
121
|
+
|
|
122
|
+
<!-- Font Awesome -->
|
|
123
|
+
<link
|
|
124
|
+
rel="stylesheet"
|
|
125
|
+
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
|
126
|
+
/>
|
|
127
|
+
|
|
128
|
+
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css">
|
|
129
|
+
|
|
130
|
+
<style>
|
|
131
|
+
body.dark .swagger-ui {
|
|
132
|
+
filter: invert(1) hue-rotate(180deg);
|
|
133
|
+
}
|
|
134
|
+
</style>
|
|
135
|
+
</head>
|
|
136
|
+
|
|
137
|
+
<body class="bg-white text-slate-900 transition-all">
|
|
138
|
+
|
|
139
|
+
<!-- Toggle Button -->
|
|
140
|
+
<button
|
|
141
|
+
id="theme-toggle"
|
|
142
|
+
class="fixed top-4 right-4 z-50 w-11 h-11 rounded-xl
|
|
143
|
+
bg-blue-600 text-white flex items-center justify-center
|
|
144
|
+
shadow-lg hover:bg-blue-700 transition"
|
|
145
|
+
aria-label="Toggle theme"
|
|
146
|
+
>
|
|
147
|
+
<i class="fa-solid fa-moon"></i>
|
|
148
|
+
</button>
|
|
149
|
+
|
|
150
|
+
<div id="swagger"></div>
|
|
151
|
+
|
|
152
|
+
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
|
|
153
|
+
|
|
154
|
+
<script>
|
|
155
|
+
const btn = document.getElementById('theme-toggle')
|
|
156
|
+
const icon = btn.querySelector('i')
|
|
157
|
+
|
|
158
|
+
function setTheme(theme) {
|
|
159
|
+
document.body.classList.toggle('dark', theme === 'dark')
|
|
160
|
+
document.body.classList.toggle('bg-slate-950', theme === 'dark')
|
|
161
|
+
document.body.classList.toggle('text-slate-100', theme === 'dark')
|
|
162
|
+
document.body.classList.toggle('bg-white', theme !== 'dark')
|
|
163
|
+
|
|
164
|
+
icon.className =
|
|
165
|
+
theme === 'dark'
|
|
166
|
+
? 'fa-solid fa-sun'
|
|
167
|
+
: 'fa-solid fa-moon'
|
|
168
|
+
|
|
169
|
+
localStorage.setItem('swagger-theme', theme)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
setTheme(localStorage.getItem('swagger-theme') || 'light')
|
|
173
|
+
|
|
174
|
+
btn.onclick = () => {
|
|
175
|
+
setTheme(
|
|
176
|
+
document.body.classList.contains('dark')
|
|
177
|
+
? 'light'
|
|
178
|
+
: 'dark'
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
SwaggerUIBundle({
|
|
183
|
+
url: '${jsonPath}',
|
|
184
|
+
dom_id: '#swagger',
|
|
185
|
+
documentTitle: '${title}',
|
|
186
|
+
deepLinking: true,
|
|
187
|
+
persistAuthorization: true
|
|
188
|
+
})
|
|
189
|
+
</script>
|
|
190
|
+
</body>
|
|
191
|
+
</html>
|
|
192
|
+
`
|
|
193
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export async function parseBody(req) {
|
|
2
|
+
const type = req.headers.get('content-type') || ''
|
|
3
|
+
if (type.includes('application/json')) return req.json().catch(() => null)
|
|
4
|
+
if (type.includes('text/')) return req.text().catch(() => null)
|
|
5
|
+
return null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function corsHeaders() {
|
|
9
|
+
return {
|
|
10
|
+
'Access-Control-Allow-Origin': '*',
|
|
11
|
+
'Access-Control-Allow-Headers': '*',
|
|
12
|
+
'Access-Control-Allow-Methods': '*'
|
|
13
|
+
}
|
|
14
|
+
}
|