LightningServer has a lot of nifty features. It minimizes boilerplate code making it easy to get a server up and running, and simplifies working with a database and building REST APIs. Here is a step-by-step example of how to program a server in LightningServer that will run locally on your machine.
To run a server with LightningServer, you simply need to call loadSettings()
followed by runServer()
:
fun main() {
loadSettings(File("settings.json"))
runServer(LocalPubSub, LocalCache)
}
The first time you try to run this, the program should throw an error saying Need a settings file - example generated at ...
and a file called settings.json
should have been created in your project's directory. You should be able to
run the server a second time without errors.
If you want your server to actually do anything, you need to implement HttpEndpoints
. In LightningServer, an
HttpEndpoint
is the combination of a ServerPath
and an HttpMethod
(such as get
,
post
, put
, etc.). HttpEndpoints
are created within a user-defined object called Server
, which will inherit from
an abstract class called ServerPathGroup
. Create this object in a new file called Server.kt
(make sure to include it in the same package):
object Server : ServerPathGroup(ServerPath.root) {
val root = path.get.handler { HttpResponse.plainText("Hello World!") }
}
Additionally, you'll need to call it in main:
fun main() {
Server
loadSettings(File("settings.json"))
runServer(LocalPubSub, LocalCache)
}
If you make an HTTP get request to localhost::8080
, you'll get the response "Hello World!"
, as specified in the
server code. How does it work? The Server
object inherits from ServerPathGroup
, which is passed ServerPath.root
.
This sets up your object so that any ServerPath
added to it (or HttpEndpoint
with a ServerPath
tied to it) gets
added to the server's root path, allowing it to handle requests.
Inside the object, path.get.handler {}
is called. This lambda simply creates an HTTP get endpoint and expects
an HttpResponse
object as a return. Inside the lambda, an HttpResponse
is set with the string "Hello World!"
,
which is what the server would have responded with if you made a request.
This example uses an HTTP get
handler, but LightningServer provides handlers for HTTP get
, post
, put
,
patch
, delete
, and head
methods as well.
LightningServer provides an abstract class called ServerPathGroup
, which you inherited from for your Server
object.
This class simply appends all of its ServerPaths
and HttpEndpoints
to whatever path was passed to it. Looking back
at the server, you have it like so:
object Server : ServerPathGroup(ServerPath.root) {
// body...
}
Therefore, this setup applies all the ServerPaths
and HttpEndpoints
in the body of the Server
object to the
server's root path.
You may have noticed that the get lambda has access to an HttpRequest
object. You can use this object to access all
the data from the HTTP request that hit the endpoint. Here is a simple setup that demonstrates how to obtain data from
the HttpRequest
in LightningServer:
object Server : ServerPathGroup(ServerPath.root) {
private var counter: Int = 0
val rootGet = path.get.handler { HttpResponse.plainText("$counter") }
val rootPost = path.post.handler {
counter = it.body!!.parse<Int>()
HttpResponse.plainText("$counter")
}
}
fun main() {
Server
loadSettings(File("settings.json"))
runServer(LocalPubSub, LocalCache)
}
In Server
, two HttpEndpoints
are defined: a get
and a post
. The get
simply returns the current value
of counter
. The post
has a bit more functionality. First, it gets the body of the HttpRequest
and makes sure that
it is not null before parsing it into an integer
with parse()
(more on why parse
is necessary). Then, it sets the value
of counter
to whatever that parsed value was. After that, it returns the value of counter
.
Something else you might want to know is that LightningServer automatically handles many things for you in parse()
.
For example, what if the HttpRequest
that was received did not contain an Int
, but a String
? In a case like this,
the Server will automatically respond with an HTTP 400 (Bad Request) status code, and the ServerPath
will exit.
LightningServer allows you to specify the path a ServerPath
is on simply by inputting a path parameter into path
.
Create this HttpEndpoint
in the Server
object:
val notRoot = path("this-is-a-path").get.handler { HttpResponse.plainText("This is a separate endpoint.") }
If you run the server and make an HTTP get
request to localhost:8080/this-is-a-path
, the server will respond with
the text This is a seperate endpoint.
Say that your server has this HttpEndpoint
:
val rootGet = path.get.handler {
HttpResponse.plainText("Hello World!")
}
Aside from this, there is another way to write an HttpEndpoint
that performs the same task:
val rootGet = path.get.typed(
summary = "Root get",
errorCases = listOf(),
implementation = { _user: Unit, _: Unit ->
"Hello World!"
}
)
This way may seem unnecessarily verbose, and for the purposes of this endpoint it is. But typed
endpoints have a lot
more functionality than handler
endpoints, and they will be used in later examples.
LightningServer has a slew of settings that you can set to get specific functionality from your server. If you want a
specific functionality, all you have to do is declare the setting in code using setting()
. For example, assume
that you want to store files locally on your server. To do this, you would call setting()
with the FilesSettings()
object at the top of your Server
object:
val files = setting("files", FilesSettings())
If you run the code now, you should get an error that reads Settings were incorrect. Suggested updates are inside ...
.
The reason for this error (and the one previously) is that LightningServer keeps the settings file
up to date with the settings declared in code every time loadSettings()
is called. If they do not
match, loadSettings()
will generate a file with suggested settings. In many cases, you may need to modify these
settings slightly, although in others it may be fine to use them as is. For your server, the suggested settings are what
you need, so you can just copy the contents of the generated settings.suggested.json
file into settings.json
. After
you've done that, you can safely delete the suggested settings file. Running the server again should work without any
errors.
Notice how the return of setting()
is stored in a constant. This is because setting()
returns a relevant object that
you can potentially use later on in your server code.
Your server's usefulness is very limited if it can't send or receive more complicated information. However, while your server code is written in Kotlin, HTTP requests are not, which means that you need a way of turning information from those HTTP requests into Kotlin data types that you can use in your server. Serialization makes this possible.
While manually getting information from the HttpRequest
with parse()
may work, in many cases (especially those
where you would like to obtain multiple data objects from the HttpRequest
) it is not efficient nor convenient. Rather,
it would be better if you were able to say what values you wanted from the request, and have LightningServer handle the
parsing of those values.
For the sake of example, rewrite the Server
code from the counter
example with typed
endpoints:
object Server : ServerPathGroup(ServerPath.root) {
val getCounter = path.get.typed(
summary = "",
errorCases = listOf(),
implementation = { _: Unit, _: Unit ->
counter
}
)
val setCounter = path.post.typed(
summary = "",
errorCases = listOf(),
implementation = { _: Unit, newValue: Int ->
counter = newValue
}
)
}
Notice how you no longer have to return an HttpResponse
. With typed
endpoints, generic kotlin types are
serialized into JSON by default. Additionally, notice how the post
accepts an Int
. This value will be automatically
taken from the body of the HTTP Request in the form of a JSON integer literal. Run the server and see this system in
action. A get request will get the value of counter
, and a post request can set the value of counter
.
Another thing to take note of is the summary
field, which isn't being used currently. This field is used for
documenting your server's endpoints.
The last example was fairly simple. You might want your server to be able to send and receive more complicated
information like your own data types. Create a new file called models.kt
. This file will contain all of your data
classes. Additionally, create a new User
model consisting of a String
first and last name that the server will use
instead of the Int
setup from before. Because this data needs to be serialized in your endpoints, you have to add
the @Serializable
annotation to the class, or LightningServer will not be able to serialize it from JSON.
@Serializable
data class User(
val firstname: String,
val lastname: String
)
Additionally, exchange the counter variable in the Server
object with a nullable User
called currentUser
:
object Server : ServerPathGroup(ServerPath.root) {
var currentUser: User? = null
val getUser = path.get.typed(
summary = "",
errorCases = listOf(),
implementation = { _: Unit, _: Unit ->
currentUser
}
)
val setUser = path.post.typed(
summary = "",
errorCases = listOf(),
implementation = { _: Unit, newUser: User ->
currentUser = newUser
}
)
}
Run this and see what happens. Like the previous example, you can get the state of the current user via a get
request,
and you can change the state of the current user via a post
request. (You can use a JSON object literal for
the User
type in your requests.)
Sometimes you may want to serialize data from an HttpRequest
into a data type that LightningServer does not have a
built-in serializer for and that you did not create yourself. In this case, you can use the
@file:UseContextualSerialization()
file annotation. Here is what that would look like for the UUID
class, which
will be used in the next section:
@file:UseContextualSerialization(UUID::class)
@Serializable
data class ExampleModel(
val id: UUID = UUID.randomUUID()
)
Without the @file:UseContextualSerialization()
annotation, the above code will produce errors.
LightningServer provides many systems for interacting with a database. To demonstrate this, the previous program will be
extended to implement a database full of Users
. First, you'll need to specify the DatabaseSettings
in your Server
object, as well as call prepareModels()
in its init
function:
object Server : ServerPathGroup(ServerPath.root) {
val database = setting("database", DatabaseSettings())
init {
prepareModels()
}
val index = path.get.handler {
HttpResponse.plainText("Hello, World!")
}
}
Next, update the models.kt
file to contain more information for the User
type and add the @DatabaseModel
annotation which you'll need for database integration:
@file:UseContextualSerialization(UUID::class)
@Serializable
@DatabaseModel
data class User(
override val _id: UUID = UUID.randomUUID()
val firstname: String,
val lastname: String
) : HasId<UUID>
Here the User
model is written to use a UUID
. This is so that every User
in the database can be referenced by a
unique id, ensuring that you won't have any issues with multiple Users
having similar information.
The User
model is also written to inherit from HasId
, an interface in LightningServer that provides the _id
field
so that the model can utilize a few functions on the database.
FieldCollections
are collections that LightningServer uses within the database. Whenever you want to add data to the
database, you'll be adding it to a FieldCollection
of that type. This keeps similar information together, and
LightningServer also provides many useful functions within the FieldCollection
to help manage the data within it.
Create a file called UserEndpoints.kt
. This file will be used solely for managing the FieldCollection
associated
with the User
model. Within the file, create this class:
class UserEndpoints(path: ServerPath) : ServerPathGroup(path), ModelInfoWithDefault<User?, User, UUID> {
private val collection: FieldCollection<User> by lazy {
Server.database().collection()
}
override val serialization: ModelSerializationInfo<User?, User, UUID> = ModelSerializationInfo()
override fun collection(): FieldCollection<User> = collection
override suspend fun defaultItem(user: User?): User = User(
firstname = "",
lastname = ""
)
override suspend fun collection(principal: User?): FieldCollection<User> {
val everybody = Condition.Always<User>()
return collection.withPermissions(
ModelPermissions(
create = everybody,
read = everybody,
update = everybody,
delete = everybody
)
)
}
val rest = ModelRestEndpoints(path("rest"), this)
val restWebsockets = path("rest").restApiWebsocket(Server.database, this)
}
This class has many parts. First off, it inherits from ModelInfoWithDefault
, an abstract class provided by
LightningServer. This is simply used for all the overridden values and functions, and you don't need to worry the actual
implementation of the class itself. The class also has a value called collection
which is by lazy
. This stores the
entire FieldCollection
associated with the User
model by grabbing it directly from the database. Aside from this,
there is an overridden serialization
constant, an overridden defaultItem()
function, and two
overridden collection()
functions, one of them a suspend
.
Taking a step back from all the members, what is the UserEndpoints
class actually going to do? Although you haven't
written any yet, this class will eventually store all the basic REST endpoints associated with the User
model.
Run the server and try making requests to it. By making a get
request to localhost:8080/users/rest
, you can see all
the Users
stored in the database. It will be empty at first. By making a post
request to the same address, you can
insert a User
into the collection.
The previous example uses a Condition
. Conditions
are used when you want to operate on information in the database
and there are many functions that require a Condition
as a parameter in LightingServer. It's important to note that
Conditions
are not functions that return booleans, nor are they equivalent to if statements. Condition
is a class.
Here is one way to define a Condition
:
Condition.Always()
This creates a Condition
that calls Always()
, a shorthand for setting the Condition
to always be true
.
Conversely, you can also set the Condition
to never be true (or rather to always be false) with Never()
:
Condition.Never()
You can also create a Condition
using the function condition()
, which has an internal syntax using infix operators:
condition { it: User -> it.firstname eq "Test First Name" }
Given a User
, this Condition
would return true if the firstname
on it equals the string "Test First Name"
.
Like with normal if statements, you can test for multiple Conditions
at once using the infix operators and
and
or
, which work as you would expect. Note that you need to wrap individual conditions in parentheses:
condition {
(it.firstname eq "Test First Name") and
(it.lastname eq "Test Last Name")
}
condition {
(it.firstname eq "Test First Name") or
(it.lastname eq "Test Last Name")
}
Here is an example of how you could use this to get a list of Users
from the collection that are all admins using the
function find()
, which needs a Condition
:
val users = collection.find(condition = condition {
it.isAdmin eq true
})
You can find a full list of the operations you can perform inside a Condition
in docs-feature-list.md
.
When data is stored into the database, it is no longer directly referenced in your code. If you use
a condition
to find that data again and create an instance of that data in your code, modifying that
instance will not affect the data in the database. So how do you change existing data within the database? You use
Modifications
.
Modifications
are very similar to Conditions
syntactically. If you wanted to modify the name of
an existing User
in the database, you could write this Modification
:
modification { it.firstname assign "New First Name" }
You can also chain multiple Modifications
together using the infix operator then
. Like with Conditions
, you have
to wrap individual modifications with parentheses:
modification { (it.firstname assign "New First Name") then (it.lastname assign "New Last Name") }
Here is an example of how you could use a Modification
to modify the name of an existing User
with a given id using
the function updateOneById()
:
val userId = /* obtain user id */
collection.updateOneById(id = userId, modification = modification {
it.firstname assign "New First Name"
})
You can find a full list of the operations you can perform inside a Modification
in docs-feature-list.md
.
To start using authentication and authorization in your server, you first need to set up JWT tokens, as that is what
LightningServer uses to authenticate calls. To start using JWT tokens, you first need to declare the JwtSigner
setting in your Server
object:
object Server : ServerPathGroup(ServerPath.root) {
val database = setting("database", DatabaseSettings())
val userSigner = setting("userJwt", JwtSigner())
// ...
}
Now that you have the JwtSigner
, you also need to create a path for AuthEndpoints
. AuthEndpoints
is a
ServerPathGroup
within LightningServer that provides the basic endpoints you'll need for authentication in your
server. You can add it to your server like any other path:
object Server : ServerPathGroup(ServerPath.root) {
//...
val index = path.get.handler {
HttpResponse.plainText("Hello, ${it.user<User?>()?.firstname}!")
}
val auth = AuthEndpoints(
path = path("auth"),
userSerializer = Serialization.module.contextualSerializerIfHandled(),
idSerializer = Serialization.module.contextualSerializerIfHandled(),
authRequirement = AuthInfo<User>(),
jwtSigner = userSigner,
email = email,
userId = { it._id },
userById = {
database().collection<User>().get(it)!!
},
userByEmail = {
database().collection<User>().find(Condition.OnField(HasEmailFields.email<User>(), Condition.Equal(it)))
.singleOrNull() ?: User(email = it).let { database().collection<User>().insertOne(it) }
?: throw NotFoundException()
},
landing = "/",
emailSubject = { "${generalSettings().projectName} Log In" },
template = HtmlDefaults.defaultLoginEmailTemplate
).authEndpointExtensionHtml()
val users = UserEndpoints(path("users"))
}
Going back to UserEndpoints.kt
, lets implement permissions for the User
object. Currently, you have them like this:
override suspend fun collection(principal: User?): FieldCollection<User> {
val everybody = Condition.Always<User>()
return collection.withPermissions(
ModelPermissions(
create = everybody,
read = everybody,
update = everybody,
delete = everybody
)
)
}
Here, the User
model permissions are defined. The create
, read
, update
, and delete
fields are set to
everybody
. When someone tries one of those operations, the Condition
it is given will be checked to decide whether
they have access to that operation. Since the Condition
is set to true
via Always()
, anyone who tries any of those
operations will be granted access. You probably don't want that to be the case. Rather, you may want certain access
rights to be granted to certain Users
(admins). To implement this kind of system, you'll need to add a flag to
the User
model for whether they are an admin, and you'll need to make use of the principal
parameter, which contains
the given User
attempting to make the request. Using these bits of information together, you can write a
new Condition
:
data class User(
override val _id: UUID = UUID.randomUUID()
val firstname: String,
val lastname: String,
val isAdmin: Boolean
) : HasId<UUID>
override suspend fun collection(principal: User?): FieldCollection<User> {
val admins = if (principal?.isAdmin == true) Condition.Always<User>() else Condition.Never<User>()
return collection.withPermissions(
ModelPermissions(
create = admins,
read = admins,
update = admins,
delete = admins
)
)
}
This all works just fine. Only Users
with the isAdmin
flag set to true will be able to access the operations. But
what about a user trying to access operations for their own User
? Rather, what if a User
wanted to be able to
read and write their own fields, such as their firstname
and lastname
? To do this, you'll have to have multiple
Conditions
, and this example will also show you another way to create one:
override suspend fun collection(principal: User?): FieldCollection<User> {
val admins = if (principal?.isAdmin == true) Condition.Always<User>() else Condition.Never<User>()
return collection.withPermissions(
ModelPermissions(
create = admins,
read = if (principal != null) condition { it._id eq principal._id } else admins,
update = if (principal != null) condition { it._id eq principal._id } else admins,
delete = admins
)
)
}
Now, Users
can read and write their own data. This presents an interesting problem, however. You want Users
to be
able to edit their firstname
and lastname
fields, but you definitely wouldn't want them to be able to change the
_id
or isAdmin
fields. It follows that you need a way to allow Users
to edit only some of the fields on their
User
model. You can do this by using updateRestrictions()
:
override suspend fun collection(principal: User?): FieldCollection<User> {
val admins = if (principal?.isAdmin == true) Condition.Always<User>() else Condition.Never<User>()
return collection.withPermissions(
ModelPermissions(
create = admins,
read = if (principal != null) condition { it._id eq principal._id } else admins,
update = if (principal != null) condition { it._id eq principal._id } else admins,
updateRestrictions = updateRestrictions {
it._id.cannotBeModified(),
it.isAdmin.cannotBeModified()
},
delete = admins
)
)
}
This prevents the _id
and isAdmin
field from being changed. Instead of cannotBeModified()
, you can also use other
functions, such as requires()
, which could be used, for example, to allow only admins to change the isAdmin
field on
a User
:
override suspend fun collection(principal: User?): FieldCollection<User> {
val admins = if (principal?.isAdmin == true) Condition.Always<User>() else Condition.Never<User>()
return collection.withPermissions(
ModelPermissions(
create = admins,
read = if (principal != null) condition { it._id eq principal._id } else admins,
update = if (principal != null) condition { it._id eq principal._id } else admins,
updateRestrictions = updateRestrictions {
it._id.cannotBeModified(),
it.isAdmin.requires(admins)
},
delete = admins
)
)
}
You can also apply similar granular restrictions to reading the fields as well. This is done using readMask
:
override suspend fun collection(principal: User?): FieldCollection<User> {
val admins = if (principal?.isAdmin == true) Condition.Always<User>() else Condition.Never<User>()
return collection.withPermissions(
ModelPermissions(
create = admins,
read = if (principal != null) condition { it._id eq principal._id } else admins,
readMask = mask {
it._id.maskedTo(null).unless(admins),
it.isAdmin.maskedTo(null).unless(admins)
},
update = if (principal != null) condition { it._id eq principal._id } else admins,
updateRestrictions = updateRestrictions {
it._id.cannotBeModified(),
it.isAdmin.requires(admins)
},
delete = admins
)
)
}
This code sets the model up so that only admins can read and write the _id
and isAdmin
properties on a given User
model. If a User
with the isAdmin
field set to false tries a read on another User
, they will only obtain the
firstname
and lastname
fields, as the others will be masked to null
.
There's a good chance that after you've written your server you'll want to document your endpoints and everything that a
user would need to know to make requests to those endpoints. Because of this, LightingServer provides a shortcut
function apiHelp()
that automatically documents every typed
endpoint within your server (printing out given
summary
text), models you've created that use the @Serializable
annotation, and other Kotlin types that your server
uses serialization for, as well as how to make write all of those in JSON. Here is how you can use apiHelp()
within
your Server
object:
object Server : ServerPathGroup(ServerPath.root) {
/* typed endpoints and other server code */
val docs = path("docs").apiHelp()
}
As you can see, this is extremely simple to use within your code. This is also another great reason for why you should
mainly use typed
endpoints.