Skip to content
/ xun Public

Xun is an HTTP web framework built on Go's built-in html/template and net/http package’s router (1.22).

License

Notifications You must be signed in to change notification settings

yaitoo/xun

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

74 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Xun

Xun is an HTTP web framework built on Go's built-in html/template and net/http package’s router.

Xun [ʃʊn] (pronounced 'shoon'), derived from the Chinese character 迅, signifies being lightweight and fast.

License Tests Go Reference Codecov GitHub Release Go Report Card

Features

  • Works with Go's built-in net/http.ServeMux router that was introduced in 1.22. Routing Enhancements for Go 1.22.
  • Works with Go's built-in html/template. It is built-in support for Server-Side Rendering (SSR).
  • Built-in response compression support for gzip and deflate.
  • Built-in Form and Validate feature with i18n support.
  • Built-in AutoTLS feature. It automatic SSL certificate issuance and renewal through Let's Encrypt and other ACME-based CAs
  • Support Page Router in StaticViewEngine and HtmlViewEngine.
  • Support multiple viewers by ViewEngines: StaticViewEngine, JsonViewEngine and HtmlViewEngine. You can feel free to add custom view engine, eg XmlViewEngine.
  • Support to reload changed static files automatically in development environment.

Getting Started

See full source code on xun-examples

Install Xun

  • install latest commit from main branch
go get github.com/yaitoo/xun@main
  • install latest release
go get github.com/yaitoo/xun@latest

Project structure

Xun has some specified directories that is used to organize code, routing and static assets.

  • public: Static assets to be served.
  • components A partial view that is shared between layouts/pages/views.
  • views: A internal page view. It is used in context.View to render different view from current routing.
  • layouts: A layout is shared between multiple pages/views
  • pages: A public page view. It also is used to automatically create a accessible page routing.

NB: All html files(component,layout, view and page) will be parsed by html/template. You can feel free to use all built-in Actions,Pipelines and Functions, and your custom functions that is registered in HtmlViewEngine.

Layouts and Pages

Xun uses file-system based routing, meaning you can use folders and files to define routes. This section will guide you through how to create layouts and pages, and link between them.

Creating a page

A page is UI that is rendered on a specific route. To create a page, add a page file(.html) inside the pages directory. For example, to create an index page (/):

└── app
    └── pages
        └── index.html

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Xun-Admin</title>
  </head>
  <body>
    <div id="app">hello world</div>
  </body>
</html>

Creating a layout

A layout is UI that is shared between multiple pages/views.

You can create a layout(.html) file inside the layouts directory.

└── app
    ├── layouts
    │   └── home.html
    └── pages
        └── index.html

layouts/home.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Xun-Admin</title>
  </head>
  <body>
    {{ block "content" .}} {{ end }}
  </body>
</html>

pages/index.html

<!--layout:home-->
{{ define "content" }}
    <div id="app">hello world</div>
{{ end }}

Static assets

You can store static files, like images, fonts, js and css, under a directory called public in the root directory. Files inside public can then be referenced by your code starting from the base URL (/).

NB: public/index.html will be exposed by / instead of /index.html.

Creating a component

A component is a partial view that is shared between multiple layouts/pages/views.

└── app
    ├── components
    │   └── assets.html
    ├── layouts
    │   └── home.html
    ├── pages
    │   └── index.html
    └── public
        ├── app.js
        └── skin.css

components/assets.html

<link rel="stylesheet" href="/skin.css">
<script type="text/javascript" src="/app.js"></script>

layouts/home.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Xun-Admin</title>
    {{ block "components/assets" . }} {{ end }}
  </head>
  <body>
    {{ block "content" .}} {{ end }}
  </body>
</html>

Building your application

Routing

Route Handler

Page Router only serve static content from html files. We have to define router handler in go to process request and bind data to the template file via HtmlViewer.

pages/index.html

<!--layout:home-->
{{ define "content" }}
    <div id="app">hello {{.Name}}</div>
{{ end }}

main.go

	app.Get("/{$}", func(c *xun.Context) error {
		return c.View(map[string]string{
			"Name": "go-xun",
		})
	})

NB: An /index.html always be registered as /{$} in routing table. See more detail on Routing Enhancements for Go 1.22.

There is one last bit of syntax. As we showed above, patterns ending in a slash, like /posts/, match all paths beginning with that string. To match only the path with the trailing slash, you can write /posts/{$}. That will match /posts/ but not /posts or /posts/234.

Dynamic Routes

When you don't know the exact segment names ahead of time and want to create routes from dynamic data, you can use Dynamic Segments that are filled in at request time. {var} can be used in folder name and file name as same as router handler in http.ServeMux.

For examples, below patterns will be generated automatically, and registered in routing table.

  • /user/{id}.html generates pattern /user/{id}
  • /{id}/user.html generates pattern /{id}/user
├── app
│   ├── components
│   │   └── assets.html
│   ├── layouts
│   │   └── home.html
│   ├── pages
│   │   ├── index.html
│   │   └── user
│   │       └── {id}.html
│   └── public
│       ├── app.js
│       └── skin.css
├── go.mod
├── go.sum
└── main.go

pages/user/{id}.html

<!--layout:home-->
{{ define "content" }}
    <div id="app">hello {{.Name}}</div>
{{ end }}

main.go

	app.Get("/user/{id}", func(c *xun.Context) error {
		id := c.Request().PathValue("id")
		user := getUserById(id)
		return c.View(user)
	})

Multiple Viewers based on MIME request

In our application, a routing can have multiple viewers. Response is render based on the request header Accept. Default viewer is used if there is no any viewer is matched by Accept. The built-it default viewer is JsonViewer. But it can be overridden by xun.WithViewer in xun.New. see more examples on Tests

curl -v http://127.0.0.1

> GET / HTTP/1.1
> Host: 127.0.0.1
> User-Agent: curl/8.7.1
> Accept: */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Date: Thu, 26 Dec 2024 07:46:13 GMT
< Content-Length: 19
< Content-Type: text/plain; charset=utf-8
<
{"Name":"go-xun"}

curl --header "Accept: text/html; */*" http://127.0.0.1

> GET / HTTP/1.1
> Host: 127.0.0.1
> User-Agent: curl/8.7.1
> Accept: text/html; */*
>
* Request completely sent off
< HTTP/1.1 200 OK
< Date: Thu, 26 Dec 2024 07:49:47 GMT
< Content-Length: 343
< Content-Type: text/html; charset=utf-8
<
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Xun-Admin</title>
    <link rel="stylesheet" href="/skin.css">
<script type="text/javascript" src="/app.js"></script>
  </head>
  <body>

    <div id="app">hello go-xun</div>

  </body>
</html>

Middleware

Middleware allows you to run code before a request is completed. Then, based on the incoming request, you can modify the response by rewriting, redirecting, modifying the request or response headers, or responding directly.

Integrating Middleware into your application can lead to significant improvements in performance, security, and user experience. Some common scenarios where Middleware is particularly effective include:

  • Authentication and Authorization: Ensure user identity and check session cookies before granting access to specific pages or API routes.
  • Server-Side Redirects: Redirect users at the server level based on certain conditions (e.g., locale, user role).
  • Path Rewriting: Support A/B testing, feature rollout, or legacy paths by dynamically rewriting paths to API routes or pages based on request properties.
  • Bot Detection: Protect your resources by detecting and blocking bot traffic.
  • Logging and Analytics: Capture and analyze request data for insights before processing by the page or API.
  • Feature Flagging: Enable or disable features dynamically for seamless feature rollout or testing.

Authentication

	admin := app.Group("/admin")

	admin.Use(func(next xun.HandleFunc) xun.HandleFunc {
		return func(c *xun.Context) error {
			token := c.Request().Header.Get("X-Token")
			if !checkToken(token) {
				c.WriteStatus(http.StatusUnauthorized)
				return xun.ErrCancelled
			}
			return next(c)
		}
	})

Logging

	app.Use(func(next xun.HandleFunc) xun.HandleFunc {
		return func(c *xun.Context) error {
			n := time.Now()
			defer func() {
				duration := time.Since(n)

				log.Println(c.Routing.Pattern, duration)
			}()
			return next(c)
		}
	})

Multiple VirtualHosts

net/http package's router supports multiple host names that resolve to a single address by precedence rule. For examples

 mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {...})
 mux.HandleFunc("GET abc.com/", func(w http.ResponseWriter, req *http.Request) {...})
 mux.HandleFunc("GET 123.com/", func(w http.ResponseWriter, req *http.Request) {...})

In Page Router, we use @ in top folder name to setup host rules in routing table. See more examples on Tests

├── app
│   ├── components
│   │   └── assets.html
│   ├── layouts
│   │   └── home.html
│   ├── pages
│   │   ├── @123.com
│   │   │   └── index.html
│   │   ├── index.html
│   │   └── user
│   │       └── {id}.html
│   └── public
│       ├── @abc.com
│       │   └── index.html
│       ├── app.js
│       └── skin.css

Form and Validate

In an api application, we always need to collect data from request, and validate them. It is integrated with i18n feature as built-in feature now.

check full examples on Tests

type Login struct {
		Email  string `form:"email" json:"email" validate:"required,email"`
		Passwd string `json:"passwd" validate:"required"`
	}

BindQuery

	app.Get("/login", func(c *Context) error {
		it, err := xun.BindQuery[Login](c.Request())
		if err != nil {
			c.WriteStatus(http.StatusBadRequest)
			return ErrCancelled
		}

		if it.Validate(c.AcceptLanguage()...) && it.Data.Email == "xun@yaitoo.cn" && it.Data.Passwd == "123" {
			return c.View(it)
		}
		c.WriteStatus(http.StatusBadRequest)
		return ErrCancelled
	})

BindForm

app.Post("/login", func(c *Context) error {
		it, err := xun.BindForm[Login](c.Request())
		if err != nil {
			c.WriteStatus(http.StatusBadRequest)
			return ErrCancelled
		}

		if it.Validate(c.AcceptLanguage()...) && it.Data.Email == "xun@yaitoo.cn" && it.Data.Passwd == "123" {
			return c.View(it)
		}
		c.WriteStatus(http.StatusBadRequest)
		return ErrCancelled
	})

BindJson

app.Post("/login", func(c *Context) error {
		it, err := xun.BindJson[Login](c.Request())
		if err != nil {
			c.WriteStatus(http.StatusBadRequest)
			return ErrCancelled
		}

		if it.Validate(c.AcceptLanguage()...) && it.Data.Email == "xun@yaitoo.cn" && it.Data.Passwd == "123" {
			return c.View(it)
		}
		c.WriteStatus(http.StatusBadRequest)
		return ErrCancelled
	})

Validate Rules

Many baked-in validations are ready to use. Please feel free to check docs and write your custom validation methods.

i18n

English is default locale for all validate message. It is easy to add other locale.

import(
  "github.com/go-playground/locales/zh"
  ut "github.com/go-playground/universal-translator"
  trans "github.com/go-playground/validator/v10/translations/zh"

)

xun.AddValidator(ut.New(zh.New()).GetFallback(), trans.RegisterDefaultTranslations)

check more translations on here

Extensions

GZip/Deflate handler

Set up the compression extension to interpret and respond to Accept-Encoding headers in client requests, supporting both GZip and Deflate compression methods.

app := xun.New(WithCompressor(&GzipCompressor{}, &DeflateCompressor{}))

AutoTLS

Use autotls.Configure to set up servers for automatic obtaining and renewing of TLS certificates from Let's Encrypt.

	mux := http.NewServeMux()

	app := xun.New(xun.WithMux(mux))

	//...

	httpServer := &http.Server{
		Addr: ":http",
		//...
	}

	httpsServer := &http.Server{
		Addr: ":https",
		//...
	}

	autotls.
		New(autotls.WithCache(autocert.DirCache("./certs")),
			autotls.WithHosts("abc.com", "123.com")).
		Configure(httpServer, httpsServer)

	go httpServer.ListenAndServe()
	go httpsServer.ListenAndServeTLS("", "")

Works with tailwindcss

Install Tailwind CSS

Install tailwindcss via npm, and create your tailwind.config.js file.

npm install -D tailwindcss
npx tailwindcss init

Configure your template paths

Add the paths to all of your template files in your tailwind.config.js file.

tailwind.config.js

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ["./app/**/*.{html,js}"],
  theme: {
    extend: {},
  },
  plugins: [],
}

Add the Tailwind directives to your CSS

Add the @tailwind directives for each of Tailwind’s layers to your main CSS file.

app/tailwind.css

@tailwind base;
@tailwind components;
@tailwind utilities;

Start the Tailwind CLI build process

Run the CLI tool to scan your template files for classes and build your CSS.

npx tailwindcss -i ./app/tailwind.css -o ./app/public/theme.css --watch

Start using Tailwind in your HTML

Add your compiled CSS file to the assets.html and start using Tailwind’s utility classes to style your content.

components/assets.html

<link rel="stylesheet" href="/skin.css">
<link rel="stylesheet" href="/theme.css">
<script type="text/javascript" src="/app.js"></script>

Works with htmx.js

Add new pages

pages/admin/index.html and pages/login.html

├── app
│   ├── components
│   │   └── assets.html
│   ├── layouts
│   │   └── home.html
│   ├── pages
│   │   ├── @123.com
│   │   │   └── index.html
│   │   ├── admin
│   │   │   └── index.html
│   │   ├── index.html
│   │   ├── login.html
│   │   └── user
│   │       └── {id}.html
│   ├── public
│   │   ├── @abc.com
│   │   │   └── index.html
│   │   ├── app.js
│   │   ├── skin.css
│   │   └── theme.css
│   ├── tailwind.css

Install htmx.js

components/assets.html

<link rel="stylesheet" href="/skin.css">
<link rel="stylesheet" href="/theme.css">
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
<script type="text/javascript" src="/app.js"></script>

Enabled htmx feature on pages

pages/index.html

<!--layout:home-->
{{ define "content" }}
    <div id="app" class="text-3xl font-bold underline" hx-boost="true">
        <span>hello {{.Name}}</span>

        <a href="/admin/">admin</a>
    </div>

{{ end }}

pages/login.html

<!--layout:home-->
{{ define "content" }}

<div class="flex min-h-full flex-col justify-center px-6 py-12 lg:px-8">
  <div class="sm:mx-auto sm:w-full sm:max-w-sm">
    <h2 class="mt-10 text-center text-2xl/9 font-bold tracking-tight text-gray-900">Sign in to your account</h2>
  </div>

  <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
    <form class="space-y-6" action="#" method="POST" hx-post="/login">
      <div>
        <label for="email" class="block text-sm/6 font-medium text-gray-900">Email address</label>
        <div class="mt-2">
          <input type="email" name="email" id="email" autocomplete="email" required class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6">
        </div>
      </div>

      <div>
        <div class="flex items-center justify-between">
          <label for="password" class="block text-sm/6 font-medium text-gray-900">Password</label>
        </div>
        <div class="mt-2">
          <input type="password" name="password" id="password" autocomplete="current-password" required class="block w-full rounded-md bg-white px-3 py-1.5 text-base text-gray-900 outline outline-1 -outline-offset-1 outline-gray-300 placeholder:text-gray-400 focus:outline focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6">
        </div>
      </div>

      <div>
        <button type="submit" class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm/6 font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign in</button>
      </div>
    </form>
  </div>
</div>

{{ end }}

pages/admin/index.html

<!--layout:home-->
{{ define "content" }}
    <div id="app" class="text-3xl font-bold underline">Hello admin: {{.Name}}</div>
{{ end }}

Setup Hx-Trigger listener

app.js

window.addEventListener("DOMContentLoaded", (event) => {
  document.body.addEventListener("showMessage", function(evt){
    alert(evt.detail.value);
  })
});

Apply htmx interceptor

	app := xun.New(xun.WithInterceptor(htmx.New()))

Create router handler to process request

create an admin group router, and apply a middleware to check if it's logged. if not, redirect to /login.

admin := app.Group("/admin")

	admin.Use(func(next xun.HandleFunc) xun.HandleFunc {
		return func(c *xun.Context) error {
			s, err := c.Request().Cookie("session")
			if err != nil || s == nil || s.Value == "" {
				c.Redirect("/login?return=" + c.Request().URL.String())
				return xun.ErrCancelled
			}

			c.Set("session", s.Value)
			return next(c)
		}
	})

	admin.Get("/{$}", func(c *xun.Context) error {
		return c.View(User{
			Name: c.Get("session").(string),
		})
	})

	app.Post("/login", func(c *xun.Context) error {

		it, err := xun.BindForm[Login](c.Request())

		if err != nil {
			c.WriteStatus(http.StatusBadRequest)
			return xun.ErrCancelled
		}

		if !it.Validate(c.AcceptLanguage()...) {
			c.WriteStatus(http.StatusBadRequest)
			return c.View(it)
		}

		if it.Data.Email != "xun@yaitoo.cn" || it.Data.Password != "123" {
			htmx.WriteHeader(c,htmx.HxTrigger, htmx.HxHeader[string]{
				"showMessage": "Email or password is incorrect",
			})
			c.WriteStatus(http.StatusBadRequest)
			return c.View(it)
		}

		cookie := http.Cookie{
			Name:     "session",
			Value:    it.Data.Email,
			Path:     "/",
			MaxAge:   3600,
			HttpOnly: true,
			Secure:   true,
			SameSite: http.SameSiteLaxMode,
		}

		http.SetCookie(c.Writer(), &cookie)

		c.Redirect(c.RequestReferer().Query().Get("return"))
		return nil
	})

Contributing

Contributions are welcome! If you're interested in contributing, please feel free to contribute to Xun

License

Apache-2.0 license

About

Xun is an HTTP web framework built on Go's built-in html/template and net/http package’s router (1.22).

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 3

  •  
  •  
  •