Building a simple demo rest api with goravel

The why?

A couple of days ago i stumbled across the open source project https://www.goravel.dev/ - which basically claims to be a golang "port" of the probably most used PHP framework laravel.

What may be interesting to mention at this point is, that while in my job i accumulated a lot of experience with PHP and also quite some year's with laravel - tho when coding in private i currently default to golang.

I think it is quite obvious why this project caught my interest.

So we got the framework, now we just need to code something using it!

Since i wanted to use at least some of the usual laravel utils, i decided to write a simple rest api providing an file hosting service.

Due to this being a test to get a peak into developing with goravel - the project will just touch a couple of base functionality. I decided not to use any database for this demo, since i wanted to keep it simple to checkout and run without any further dependencies than go itself.

The how?

Prerequisites

Due to the nature of goravel being a golang framework, you need to have a golang environment setup in order to run it. Explaining it in detail would be out of scope therefor i refer to this install tutorial made by the go team themself https://go.dev/doc/install

Installing

To install goravel you can simply follow the guide provided by the goravel team themself https://www.goravel.dev/getting-started/installation.html. Since such guides often are subject to regular updates, i prefer to refer to their version instead of copying it which in some months maybe just will stop working due to changes.

Developing

Ok, so we got our golang running and we got goravel installed.

Let's run the following command in the projects root directory


go run .
    

which will prompt an output like


GET        /
GET        /users/{id}
[HTTP] Listening and serving HTTP on (http://127.0.0.1:3000)
    

We can see that there are two default registered routes, and also the ip/domain:port our instance is running on. By opening the url in the browser we spot the default index page

defaultpage

Ok, lets check for those routes. Routes in laravel usually are registered in the "routes/(web|api).php", we are going to check the similar "routes/(web|api).go" files.

If at this point your IDE cant resolve the imports of said files, you maybe need to enable gomodule support.

First we check the "api.go"


package routes

import (
	"github.com/goravel/framework/facades"

	"goravel/app/http/controllers"
)

func Api() {
	userController := controllers.NewUserController()
	facades.Route().Get("/users/{id}", userController.Show)
}
    

Here we can see the user controller getting registered at "/user/{id}" which includes a path parameter - this is matching the previous output we spotted "GET /users/{id}".

Since we want to create a REST demo we are going to create a controller. Like laravel - goravel also provides an artisan console which we are going to use to create our desired "file_controller".


go run . artisan make:controller fileController
    

results in


Controller created successfully
    

Great! Now when checking the contents of "app/http/controllers/" we find a "file_controller.go" that has been created as result of our artisan command. Let's check the inside


package controllers

import (
	"github.com/goravel/framework/contracts/http"
)

type FileController struct {
	//Dependent services
}

func NewFileController() *FileController {
	return &FileController{
		//Inject services
	}
}

func (r *FileController) Index(ctx http.Context) http.Response {
	return nil
}
    

We can see the file created by artisan contains the minimum necessary structure for a controller. This includes the controllers struct definition, a method to retrieve a new instance of said struct, and an example method "Index" registered onto the "FileController" struct.

In preparation for later on we adjust the "Index" function name to "Upload", and add a similar function "Get".


func (r *FileController) Upload(ctx http.Context) http.Response {
	return nil
}

func (r *FileController) Get(ctx http.Context) http.Response {
    return nil
}
    

Going back to the previously opened "routes/api.go" we adjust its content like this


func Api() {
    fileController := controllers.NewFileController()
    facades.Route().Get("/api/file/{ident}", fileController.Get)
    facades.Route().Post("/api/file", fileController.Upload)
}
    

To further cleanup we delete the "app/http/controllers/user_controller.go".

It's time to check the "routes/web.go" for contents


package routes

import (
	"github.com/goravel/framework/contracts/http"
	"github.com/goravel/framework/facades"
	"github.com/goravel/framework/support"
)

func Web() {
	facades.Route().Get("/", func(ctx http.Context) http.Response {
		return ctx.Response().View().Make("welcome.tmpl", map[string]any{
			"version": support.Version,
		})
	})
}
    

Here we see the index action registered to "/" path. Instead of using a controller this registration directly provides a closure returning a provided view, in this case the "welcome.tmpl".

After checking the "resources/views/" directory we find the "welcome.tmpl". The content of this file is too large to paste it here - lets just say it contains the whole content of the page we saw earlier when opening http://127.0.0.1:3000 in the browser.

Since we want to create a small file hosting application, we are going to use this method to provide the upload form. So we rename the file to "form.tmpl" and adjust the content to


{{ define "form.tmpl" }}
<!DOCTYPE html>
<html>
    <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    
    <title>file upload</title>
    
    </head>
    <body>
        <div>
            <h1>File upload:</h1>
            <p>Please choose your file and press the upload button</p>
            <input type="file" id="fileInput">
            <button id="upload">Upload</button>
        </div>
        <script type="text/javascript">
            const fileInput = document.getElementById('fileInput');
            const uploadButton = document.getElementById('upload');
            
            uploadButton.addEventListener('click', async () => {
                const selectedFile = fileInput.files[0];
                
                if (!selectedFile) {
                    alert('Please select a file to upload');
                    return;
                }
                
                const formData = new FormData();
                formData.append('file', selectedFile); // Attach the file to the form data
                
                try {
                    const response = await fetch('/api/file', {
                        method: 'POST',
                        body: formData,
                    });
                
                    if (!response.ok) {
                        throw new Error(`HTTP error: ${response.status}`);
                    }
                    
                    const data = await response.json();
                    const generatedUrl = data.data.url;
                    alert(`File uploaded! Generated URL: ${generatedUrl}`);
                } catch (error) {
                    alert(`Error uploading file: ${error.message}`);
                }
            });
        </script>
    </body>
</html>
{{ end }}
    

This form will send a file upload to the path registered to our "file_controller.Upload" function, expect a json result and print returning info in an javascript alert.

Now we adjust the "routes/web.go" to look like


func Web() {
	facades.Route().Get("/", func(ctx http.Context) http.Response {
		return ctx.Response().View().Make("form.tmpl", map[string]any{})
	})
}
    

We removed the now obsolete version parameter and adjusted the .tmpl filename.

If we now restart our goravel instance and check the http://127.0.0.1:3000 we spot the simple form

formimage

Note: goravel has support for live reloading using https://github.com/cosmtrek/air. You can choose to use air while editing instead of rerunning the goravel service again after each change. After installing "air" simply run


air
    

in the project root directory and it will run the server with auto reload on changes. To adjust the auto reloading configs you can adjust the ".air.toml" file.

Ok, at this point we registered all routes we need, we created a simple upload form, which is delivered on index action - now all we need to implement are the actual "file_controller" functions.

First we are going to implement the "Upload" function. This function should retrieve the file from request, store it in local filesystem with a generic name, and provide a json response containing either an error message or the public link to retrieve the file.

So we adjust the file_controller's Upload method using the ctx to get the uploaded file like


file, err := ctx.Request().File("file")
    

quite simple thanks to the Request() utilities. We also use the Storage() facade to write the file to disk like


_, err = facades.Storage().PutFileAs("", file, fileIdentificator)
    

than we combine this and add simple error and response handling


func (r *FileController) Upload(ctx http.Context) http.Response {
    file, err := ctx.Request().File("file")
    
    fileIdentificator := buildFileIdentificator(file.GetClientOriginalName())
    _, err = facades.Storage().PutFileAs("", file, fileIdentificator)
    
    if nil != err {
        return ctx.Response().Status(422).Json(map[string]string{"error": "error writing file" + err.Error()})
    }
    
    ret := map[string]map[string]string{}
    ret["data"] = map[string]string{"url": "http://127.0.0.1:3000/api/file/" + fileIdentificator}
    
    return ctx.Response().Json(http.StatusCreated, ret)
}
    

As you may have spotted we are using "buildFileIdentificator" function to create the filename we use to store it. This method will create a filname which is url safe and somehow "unique". In our case we build a simple function that will use the original filename and the current timestamp to generate a hash, add the original files ext and return the string.


func buildFileIdentificator(origFileName string) string {
	ext := filepath.Ext(origFileName)
	unixTime := time.Now().Unix()
	str := origFileName + strconv.Itoa(int(unixTime))
	data := []byte(str)
	hasher := md5.New()
	hasher.Write(data)
	hash := hasher.Sum(nil)
	hashedString := hex.EncodeToString(hash)
	finalName := hashedString + ext
	return finalName
}
    

If we now use our simple form to choose a file and submit it, the alert will look like


File uploaded! Generated URL: http://127.0.0.1:3000/api/file/3dbc2f6b64837f1bd731e9703e587689.JPG
    

Checking the "storage/app/" directory we spot the "3dbc2f6b64837f1bd731e9703e587689.JPG" file.

Great - now we got a form delivered on index and a working Upload - the last thing we need is the file retrieval function we previously defined in the file_controller named "Get".

We start by retrieving the path parameter using


ident := ctx.Request().Route("ident")
    

than check if the file exists, and if so we can respond it.


if facades.Storage().Exists(ident) {
    ext := filepath.Ext(ident)
    imageData, _ := facades.Storage().Get(ident)
    return ctx.Response().Data(200, "image/"+ext[1:], []byte(imageData))
}
    

This was easy, now lets add some error responses and combine it to the following complete "Get" method


func (r *FileController) Get(ctx http.Context) http.Response {
	ident := ctx.Request().Route("ident")
	if facades.Storage().Exists(ident) {
		ext := filepath.Ext(ident)
		imageData, err := facades.Storage().Get(ident)
		if err != nil {
			return ctx.Response().Status(500).Json(map[string]string{"error": "error reading file " + err.Error()})
		}
		return ctx.Response().Data(200, "image/"+ext[1:], []byte(imageData))
	}
	return ctx.Response().Status(404).Json(map[string]string{"error": "unknown file requested"})
}
    

Note: yes the mime hack only works for png/jpeg .) demo purposes

If we now execute the form again, copy the url from the alert and request it, the api will return the file - in case of png or jpeg, due to the mime we respond, the browser will render the image.

finaluploadedimage

Profit!

With just a few steps we set up goravel, created a file upload and retrieval API just using goravel base utility.

Important: This is just an absolute simple example of how to start with a goravel project and should not be used unmodified for any other purpose than playing around. Please don't host this publicly.

The complete demo project code can be found at: https://github.com/voodooEntity/goravel-simple-rest-demo

Conclusion

Alright, folks, time to wrap up my short Goravel adventure.

Setting up Goravel was a charm. It was up and running in minutes, a truly smooth experience. Adjustments were just as straightforward thanks to Goravel's familiar structure (think Laravel) and the well-written documentation. The whole thing felt like a win – fast and enjoyable to code with.

So is goravel as "production-ready" as laravel just yet? Maybe not quite. While Goravel seems stable (almost 1900 stars on GitHub, no slouch!), it's actively developed, unlike some frameworks that go radio silent. However, the plugin scene for things like queues, databases, and file systems is a bit limited for now. That could definitely change as more people jump on board. I think we need to let some time pass to let goravel reach the level of "battle-tested" that laravel had when it took off.

While goravel kinda breaks the go idiom of "keep it as simple as possible", it enables a team to work in a unified structured way while also providing a lot of very useful utilities.

It's their stated goal to create a low entry barrier for PHP developers - and they definitely have achieved this goal - especially if you worked with laravel before.

So, ditching Goravel altogether? Nah! Even if it's not the first pick for core production systems, I can easily see myself building small, non-high-availability APIs with it and using it as test case for long-term running. Also i might use it for some personal projects.

As always, I hope this was an interesting read and maybe sparked your curiosity to give Goravel a try.

So long and thanks for all the fish