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 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
+ }