Строим свой первый Rest API с помощью GO
У нас есть три необходимые части для построения.
- API
- Rest API
- Rest API на GO
API
Если вы близки к компьютерам достаточно давно, вы возможно слышали об этих вещах. Что такое API?
API значит программный интерфейс приложения. Как и большинство вещей в информатике абревиатура не сильно помогает.
На самом деле это значит, что он выставялет функциональность без выставления внутренностй. Если ваша программа написана на языке который поддерживает функции или методы(большинство языков) вы должны понимать, о чём идет речь.
func addNumber(a, b int) int {
// DO AMAZING MATH HERE
// and return the result
}
Даже если вы совсем новичок в go, вы можете сказать, что функция возвращает результат сложения двху чисел.
Как пользователь функции вы просто вызываете функцию и не беспокоитесь о том, функция выполняет свою работу(не доверяйтся всем функциям подряд)
Это всё что такое API. API может быть функция которую вы написали, или функция из библиотеки или метод фреймворка, или http точка доступа.
Rest API
Большинство API написаные в наши дни это web api. Только не цитируйте меня, так как я не делал никаких исследований чтобы получить настоящее число. Но учитывая количество web сервисов и прилжений я не думаю, что я далеко от правды.
What is REST
REST - это акроним для REpresentational State Transfer(передача репрезентативного состояния). Это архитекторный стиль распределнной гипермедия системы и была впервые представлена Роем Филдингом в 2000 году в его известной диссертации.
Как любые другие архитектурные стили, REST так же имеет свои 6 формирующих его ограничений, которые должны быть удовлетворены и если интерфейс хочет называться RESTful. Эти принципы приведены ниже.
Ведущие принципы REST
- Клиент-сервер - Разделяя проблемы пользовательских интерфейсов от проблем хранения данных, вы улучшаем переносимость пользовательского интерфейса на многие платформы и улучшаем масштабируемость упрощением сервреных компонентов.
- Не хранит состояние - каждый запрос от клиента к серверу должен содержать всю необходимую информацию для понимания запросов, и не может воспользоваться любых сохраненным содеражением на сервере. Состояние сессии отсюда хранится полностью на клиенте.
- Кешируемость - Ограничения кэша требуют. чтобы данные в ответе на запрос были явно или не явно обозначены как кэшируемые или не кешуруемые. Если ответ кэшируемый, тогда право кэша клиента для переиспользования данные ответа в дальнейшем, похожем запросе.
- Универсальный интерфейс - Применя принцыпи програмной инженерии в целом к компонентам интерфейса, общая архитектура системы упрощена и видимость взаимодействий улучшена. Для того, чтобы получить универсальный интерфейс, множество универсальных ограничений нужны для указания поведения компонентов. REST определяется четырьмя ограничивающими интерфейсами:
- Определение ресурса
- Упралвение ресурсами через репрезентацию
- Самоописываемое сообщение
- Гипермедия - как движок состояния приложения.
- Слоёная система - Стиль слоёной системы позволяет архитектуре быть составленной из иерархических слоёв ограниченными поведением компонентов, так, что каждый компоненто не может "смотреть сковозь" слой с которым происходит взаимодействие.
- Код по запросу(по желанию) - REST позволяет расширить функциональность клиенту скачать и выполнив код в форме аплетов или скриптов. Это урощает клиентов с помощью сокращения количества особенностей требуемых для погдготовки реализации.
HTTP глаголы
Вот несколько условностей соблюдаемы HTTP API. Это не часть спецификации REST. Но нам нужно это понять, что бы использовать REST API по-понной.
HTTP определяет набор методов-запросов чтобы указать желаемое действие для данного ресурса. Так же они могут быть существительными, эти методы-запросы иногда называются HTTP глаголы. Каждый из них реализует различную семантику, но которые общие особенности поделены на группы: например запрос может быть безопасный, неизменяемый или кэшируемый.
GET
- метод запрашивает представление указанного ресурка. Запросы используещие GET
должны только получать данные.
HEAD
- метод просит ответ идентичный GET
запросу, но опускает body
.
POST
- методв используется для отправки сущности указанному ресурсу, часто является причиной изменения изменения состояния или имеет побочный эффект на сервер.
PUT
- метод заменяет все текущее представление целевого ресурса загруженным в запросе.
DELETE
- метод удаляет указанный ресурс.
CONNECT
- метод устанавливает тоннель к серверу указанному как целевой.
OPTIONS
- метод используется для описания возможностей подключения к удаленному ресурсу.
TRACE
- метод производит сообщение петлевого контроля на пути к ресурсу цели.
PATCH
- метод используется для применения частичных изменений ресурса.
ЭТО ВСЁ ВРАНЬЕ.
Статус коды
1xx Информация
100 Continue 101 Switching Protocols 102 Processing 2xx Success
200 OK 201 Created 202 Accepted 203 Non-authoritative Information 204 No Content 205 Reset Content 206 Partial Content 207 Multi-Status 208 Already Reported 226 IM Used 3xx Redirects
300 Multiple Choices 301 Moved Permanently 302 Found 303 See Other 304 Not Modified 305 Use Proxy 307 Temporary Redirect 308 Permanent Redirect 4xx Client Error
400 Bad Request 401 Unauthorized 402 Payment Required 403 Forbidden 404 Not Found 405 Method Not Allowed 406 Not Acceptable 407 Proxy Authentication Required 408 Request Timeout 409 Conflict 410 Gone 411 Length Required 412 Precondition Failed 413 Payload Too Large 414 Request-URI Too Long 415 Unsupported Media Type 416 Requested Range Not Satisfiable 417 Expectation Failed 418 I'm a teapot 421 Misdirected Request 422 Unprocessable Entity 423 Locked 424 Failed Dependency 426 Upgrade Required 428 Precondition Required 429 Too Many Requests 431 Request Header Fields Too Large 444 Connection Closed Without Response 451 Unavailable For Legal Reasons 499 Client Closed Request 5xx Server Error
500 Internal Server Error 501 Not Implemented 502 Bad Gateway 503 Service Unavailable 504 Gateway Timeout 505 HTTP Version Not Supported 506 Variant Also Negotiates 507 Insufficient Storage 508 Loop Detected 510 Not Extended 511 Network Authentication Required 599 Network Connect Timeout Error This also has no actual meaning.
Терминология
Ниже - самые важные понятия связанные с REST API.
- Ресурс - объект или представление чего-то, что имеет некоторую ассоциацию данных с ним и может иметь набор методов для обработки оных. К примеру: Живтоные, школы или работники ресурсы, а
delete
,add
,update
- операции для обработки этих данных. - Коллекции - наборы ресурсов: Компании это наборы ресурсов компаний.
- URL - это путь по которому ресурс может быть определен и действия которые необходимо с ним произвести.
API Endpoint
Вот как выглдяит пример такой точки:
https://www.github.com/golang/go/search?q=http&type=Commits
Разделим URL на части:
protocol subdomain domain path Port query
http/https subdomain base-url resource/some-other-resource some-port key value pair
https www github.com golang/go/search 80 ?q=http&type=Commits
Протоколы
Как браузер или клиент должен взаимодействовать с сервером.
Поддомен
Подраздел главного домена.
Порт
Порт сервера на котором запущено приложения. По умолчанию это 80. Но в большинстве случаем вы его не видим.
Петь
Пусть - параметры REST API отражающие ресурсы.
https://jsonplaceholder.typicode.com/posts/1/comments
posts/1/comments
Этот пусть отражает коментарии 1го поста ресурса.
Базовой структурой является
top-level-resource/<some-identifier>/secondary-resource/<some-identifier>/...
Запрос
Запросы - пары ключ-значение информации, используемый в основном для целей фильтрования.
https://jsonplaceholder.typicode.com/posts?userId=1
Часть после ?
это параметры запроса. У нас есть только один запрос тут: userID=1
.
Заголовки
Это не часть самого URL, но заголовки это часть сетевого компонента посылаемые клиентом или сервером. В зависимости от того, кто послал его. Есть 1 типа заголовков.
- Заголовок запроса (client -> server)
- Заголовок ответа (server -> client)
Тело
Вы можете добавить дополнительную инфомацию в обза запроса к серверу и к ответу от сервера.
Тип ответа
Обычно JSON или XML.
В наши дни это обычно JSON.
Rest API на GO
Это то почему вы тут. Ну или я, по крайней, мере я надеюсь на это.
Если вы пишите REST API, почему вы должны выбрать Go?
- Он компилируем. Вы получаете маленькие бинарники.
- Он быстр. Медленнее чем c/c++ или rust, но быстрее чем большинство других языков web программирования.
- Он легок в изучении.
- Он работает отлично в мире микросервисов - это причини номер 1.
net/http
Стандартная библиотека в go идущая с net/http
пакетом, что является отличной точкой отсчета для построения REST API. И большинство других библиотек добавлюят особенности тоже взаимодействуют с net/http
пакетом, поэтому понимание пакется является критичным для использования golang в качестве REST API.
net/http
Нам, возможно, не нужно знать всё в пакете net/http
. но есть несколько вещей, который мы должны знать для начала.
The Handler Interface
I am never a proposer of memorizing something but as Todd Mcleod in his course mentions over and over. We need to memorize the Handler interface. type Handler interface { ServeHTTP(ResponseWriter, *Request) } And here it is.
It has one method and one method only.
A struct or object will be Handler if it has one method ServeHTTP which takes ResponseWriter and pointer to Request.
With all our knowledge now we are ready to do some damage.
Let's Begin
I think now we are ready to get started.
That was a lot of theory. I promised you will build you first RestAPI.
Simple Rest API
So let's jump right into it.
In a folder where you want to write your go code go mod init api-test Create a new file, you can name it whatever you want.
I am calling mine main.go
package main
import (
"log"
"net/http"
)
type server struct{}
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message": "hello world"}`))
}
func main() {
s := &server{}
http.Handle("/", s)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Lets break down this code.
At the top we have our package main all go executable need a main package.
We have our imports. log for logging some error if it happens. net/http because we are writing a rest api.
Then we have a struct called server. It has no fields. We will add a method to this server ServeHTTP and that will satisfy the Handler interface. One thing you will notice in go we don't have to explicitly say the interface we are implementing. The compiler is smart enough to figure that out. In the ServeHTTP method we set httpStatus 200 to denote its the request was a success. We se the content type to application/json so the client understands when we send back json as payload. Finally we write
{"message": "hello world"}
To the response.
Lets run our server
go run main.go
If you had installed postman before, let's test our app with postman real quick.
Get returns us our message.
Great work!
But Wait.
Lets see what other HTTP verbs our application serves.
In postman we can change the Type of request we make. Click on the dropdown and select something else. Lets say we do post.
Now if we run the request, we get back the same result.
Well its not really a bug per se. But in most cases we probably want to do different things based on the request types.
Lets see how we can do that.
We will modify our ServeHTTP method with the following.
func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case "GET":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message": "get called"}`))
case "POST":
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"message": "post called"}`))
case "PUT":
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"message": "put called"}`))
case "DELETE":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message": "delete called"}`))
default:
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"message": "not found"}`))
}
}
If our server is already running lets stop it with ctrl-c
Run it again.
go run main.go
Test it with postman or curl again.
One thing you may have noticed is that we are using our server struct literally for attaching a method to.
The go team knew this was an inconvenience and gave us HandleFunc Its a method on the http package that allows us to pass a function that has the same signature as the ServeHTTP and can serve a route.
We can clean up our code a little bit with this
package main
import (
"log"
"net/http"
)
func home(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case "GET":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message": "get called"}`))
case "POST":
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"message": "post called"}`))
case "PUT":
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"message": "put called"}`))
case "DELETE":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message": "delete called"}`))
default:
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"message": "not found"}`))
}
}
func main() {
http.HandleFunc("/", home)
log.Fatal(http.ListenAndServe(":8080", nil))
}
Functionality should be exactly the same.
Gorilla Mux
net/http built in methods are great. We can write a server with no external libraries. But net/http has its limitations. There is no direct way to handle path parameters. Just like request methods we have to handle path and query parameters manually.
Gorilla Mux is a very popular library that works really well to net/http package and helps us do a few things that makes api building a breeze.
Using Gorilla Mux
To install a module we can use go get
Go get uses git under the hood.
In the same folder you have your go.mod and main.go file run
go get github.com/gorilla/mux
We change our code to this
package main
import (
"log"
"net/http"
"github.com/gorilla/mux"
)
func home(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case "GET":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message": "get called"}`))
case "POST":
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"message": "post called"}`))
case "PUT":
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"message": "put called"}`))
case "DELETE":
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message": "delete called"}`))
default:
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"message": "not found"}`))
}
}
func main() {
r := mux.NewRouter()
r.HandleFunc("/", home)
log.Fatal(http.ListenAndServe(":8080", r))
}
Looks like nothing really changed except for a new import and line 32.
HandleFunc HTTP Methods
But now we can do a little bit more with our HandleFunc Like making each function handle a specific HTTP Method.
It looks something like this
package main
import (
"log"
"net/http"
"github.com/gorilla/mux"
)
func get(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message": "get called"}`))
}
func post(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"message": "post called"}`))
}
func put(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"message": "put called"}`))
}
func delete(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message": "delete called"}`))
}
func notFound(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`{"message": "not found"}`))
}
func main() {
r := mux.NewRouter()
r.HandleFunc("/", get).Methods(http.MethodGet)
r.HandleFunc("/", post).Methods(http.MethodPost)
r.HandleFunc("/", put).Methods(http.MethodPut)
r.HandleFunc("/", delete).Methods(http.MethodDelete)
r.HandleFunc("/", notFound)
log.Fatal(http.ListenAndServe(":8080", r))
}
If you run this it should still do the exact same thing.
At this point you might be wondering how is doing the same thing with more lines of code a good thing?
But think of it this way. Our code became much cleaner and much more readable.
Clear is Better than Clever Rob Pike
Subrouter
func main() {
r := mux.NewRouter()
api := r.PathPrefix("/api/v1").Subrouter()
api.HandleFunc("", get).Methods(http.MethodGet)
api.HandleFunc("", post).Methods(http.MethodPost)
api.HandleFunc("", put).Methods(http.MethodPut)
api.HandleFunc("", delete).Methods(http.MethodDelete)
api.HandleFunc("", notFound)
log.Fatal(http.ListenAndServe(":8080", r))
}
Everything else stays the same except we are creating something called a sub-router. Sub-router are really useful when we want to support multiple resources. Helps us group the content as well as save us from retyping the same path prefix.
We move our api to api/v1 . This way we can create v2 of our api if need be..
Path and Query Parameter
package main
import (
"fmt"
"log"
"net/http"
"strconv"
"github.com/gorilla/mux"
)
func get(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message": "get called"}`))
}
func post(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write([]byte(`{"message": "post called"}`))
}
func put(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
w.Write([]byte(`{"message": "put called"}`))
}
func delete(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message": "delete called"}`))
}
func params(w http.ResponseWriter, r *http.Request) {
pathParams := mux.Vars(r)
w.Header().Set("Content-Type", "application/json")
userID := -1
var err error
if val, ok := pathParams["userID"]; ok {
userID, err = strconv.Atoi(val)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"message": "need a number"}`))
return
}
}
commentID := -1
if val, ok := pathParams["commentID"]; ok {
commentID, err = strconv.Atoi(val)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"message": "need a number"}`))
return
}
}
query := r.URL.Query()
location := query.Get("location")
w.Write([]byte(fmt.Sprintf(`{"userID": %d, "commentID": %d, "location": "%s" }`, userID, commentID, location)))
}
func main() {
r := mux.NewRouter()
api := r.PathPrefix("/api/v1").Subrouter()
api.HandleFunc("", get).Methods(http.MethodGet)
api.HandleFunc("", post).Methods(http.MethodPost)
api.HandleFunc("", put).Methods(http.MethodPut)
api.HandleFunc("", delete).Methods(http.MethodDelete)
api.HandleFunc("/user/{userID}/comment/{commentID}", params).Methods(http.MethodGet)
log.Fatal(http.ListenAndServe(":8080", r))
}
Lets look at the params functions on line 36. We handle both path param and query params.
With this you now know enough to be dangerous.
Bookdata API
In Kaggle there is a dataset for bookdata. Its a csv file with about 13000 books. We will use it to make our own bookdata api.
books.csv
You can look at the file ☝🏼 there.
Clone Repo
Let's get started.
In a separate folder
git clone https://github.com/moficodes/bookdata-api.git
Tour of the Code
There are two packages inside the code. One is called datastore, one is called loader.
Loader deals with converting the csv data into an array of bookdata objects.
Datastore deals with how we access the data. It's mainly an interface that has some methods.
R### un app From the root of the repo
run
go run .
EndPoints
The App has a few Endpoints
All api endpoints are prefixed with /api/v1
To reach any endpoint use baseurl:8080/api/v1/{endpoint}
Get Books by Author
"/books/authors/{author}"
Optional query parameter for ratingAbove ratingBelow limit and skip
Get Books by BookName
"/books/book-name/{bookName}"
Optional query parameter for ratingAbove ratingBelow limit and skip
Get Book by ISBN
"/book/isbn/{isbn}"
Delete Book by ISBN
"/book/isbn/{isbn}"
Create New Book
"/book"