In this tutorial we will build a job board application iOS app using SwiftUI and Strapi. We will learn how to extend Strapi collections, add custom routes and HTTP request handlers(controllers). We will use custom controller functions to handle file uploads associated with a specific record. We will add web sockets functionality to enable an applicant and a job advertiser to send each other messages in real time. We will use the user-permissions plugin to authenticate each socket connection.
Job boards made their debut in the internet towards the end of the 20th Century during the early stages of the Internet. The earliest job board was developed by a company named NetStart Inc in 1995. The platform allowed employers to post job openings and job seekers to search and apply for them. This was a pretty impressive website for its time and maybe considered as the pioneer of online Job boards. NetStart Inc has morphed through the years and is currently known as CareerBuilder.
Job boards have grown from plain old create, read, update and delete(CRUD) websites to websites with features such as job alerts using email and push notifications, application tracking and resume analysis. Furthermore there have emerged job boards that cater for specific industries and professions. Some can even be considered as career networking platforms where users can vouch each others strengths and skills.
Currently, there are many job boards available on the internet. Most organisations and large cooperations have a job board on their own websites where they list open positions in their workforce. In this tutorial, we will be building a job board application using SwiftUI. The application will be an iOS application that is highly dependent on a Strapi Server instance.
An Integrated Development Environment, I use VS Code but you are free to use others.
SwiftUI is a user interface (UI) toolkit that was developed by Apple in 2019. It is based upon Apple’s Swift Programming language. SwiftUI eases the process of creating UIs by providing a declarative programming model that enables developers to outline the structure and behaviour of UI components in a concise and easy to read syntax. Furthermore SwiftUI supports adaptive UI layouts which enables developers to cater for different display sizes, resolutions and orientations. This helps in streamlining the software development process for visually appealing user interfaces on Apple platforms.
By pairing SwiftUI with Strapi we are able to create applications that communicate efficiently because SwiftUI has reactive UI capabilities meaning that any data change from our Strapi backend will be rendered almost immediately in a resource efficient way. In addition to this, Strapi is relatively easy to use and could be used to create consumable Application Programming Interfaces (APIs) within minutes. When combined with SwiftUI’s prebuilt UI components, data can be quickly retrieved and displayed across all devices in Apple’s ecosystem. This reduces the development time of software project by reducing the amount of code needed to setup a fully functioning system.
Both Strapi and SwiftUI are highly customizable making them really powerful solution development tools. Strapi can be scaled to accommodate high user traffic.
To get started, we will start by setting up our Strapi server instance. Strapi is highly dependent on Node. Make sure you have it installed on your development machine. We will use the npx command which is a node package runner that will execute a script and scaffold a new strapi project inside of a project folder in the current working directory.
Open your terminal or command line prompt (cmd/terminal) and run the following command to create a scaffold of the Strapi server.
npx create-strapi-app@latest backend --quickstart
The command creates a bare-bones Strapi system, fetchs and installs necessary dependencies from the package.json file then initializes an SQLite Database. Other database management systems can be used using the following guide. For SQLite to work, you may need to install it using the following link. Once every dependency has been installed, your default browser will open and render the admin registration page for Strapi. Fill in all the required fields to create your administrator account then you’ll be welcomed with the page below.
We will use Strapi’s content-type builder plugin to define a schema which will describe the structure and rules for organizing and validating data in the database. It will guide the database on how we want our records stored. We will begin by creating a company schema, job schema and application schema. The application schema and company schema will have a relation with the user schema which was initiated after we scaffolded the strapi server. The user schema contains the necessary fields needed to identify a user of the system.
This collection will be used for a company’s details.
Company
for the Display name and click Continue.n``ame
in the Name field.address
, email
, phone
, bio
and category
within the collection.logo
. This field will be used to save a company’s logo. The logo type will be associated with uploads with image file extensions i.e .jpg and .png.User(from: users-permission)
from the dropdown and make sure the Company has one user
option is selected as shown below.The relation field is used to create association between two database tables. In our case, we are associating each user with a company. We will be able to get all the user’s details from the user-permissions
plugin. The relation we have created ensures that each company has only one user. The representative column in the quote table will be used to store the foreign key which is the user’s id. So each company has one representative in the Job Board application.
Click the Save button and wait for the changes to be applied.
This schema will be used to store job details. It will be associated with a specific company. Follow the steps below to setup the schema.
Job
for the Display Name and click Continue.name
, description
, type
, status
and environment
.company has many jobs
option is selected as shown below. The relation above ensures that each job is associated with a company. A single company can have many jobs. The field name jobs
will create a link which will be visible from the Company collection.
This schema will be responsible for storing job application details such as the applicant’s Resume and application status.
Application
for the Display Name and click Continue.status
.job
which links to the job collection and the other named applicant
which links to the user collection.The above relation ensures that a user can submit as many applications as they want. Each application is linked to a specific job. A job could have many applications as shown below.
files
option is ticked as shown below.This collection will be responsible for saving conversations between a company representative and an applicant.
Message
for the Display Name and click Continue.texts
. This field stores all the messages between the conversing parties.room
. This field will be used to store a string concatenation of the usernames of the parties involved in a conversation. Our implementation will ensure that only two people can be in a room at a time.When we created the Strapi project, a schema was generated to allow us to identify the different users that will be using our application. We are going to add more fields that can be considered as KYC parameters for our application.
first_name
as the field’s name. Do not change the default short text option.last_name
and phone_number
. profile
. This field will be used to store the user’s profile image.The final configuration of the user’s collection should have an application relation field which was automatically added when we created an association when setting up the application collection. The additional media file field will be used to store the user’s profile image.
After creating collections through the admin interface, Strapi does an excellent job of creating Create, Read, Update and Delete functions associated with each collection. However, we could override the functions and add our own logic. For example we could add some validation to ensure that only users that have a company associated with their profile can manipulate the job collection. In the next steps we are going to be implementing such validations on the application collection, company collection and job collection. We will also override default collection routes and add our custom routes and protect them using Strapi’s user-permissions plugin.
In this collection, we would like create a specific route that will allow companies to view the jobs they post. To implement this, open the job.js
file in the controllers dir(./src/api/job/controllers). Add the code below within the createCoreController function block.
1 //./src/api/jobs/controllers/job.js
2 const { createCoreController } = require('@strapi/strapi').factories;
3 module.exports = createCoreController('api::job.job', ({ strapi }) => ({
4 async myJobs(ctx) {
5 const company = await strapi.db.query('api::company.company').findOne({
6 where: { representative: ctx.state.user.id },
7 populate: {
8 jobs: {
9 populate: {
10 applications: {
11 populate: {
12 cv: true,
13 applicant: {
14 select: ['username', 'first_name', 'last_name', 'phone_number', 'email', 'id']
15 }
16 }
17 }
18 }
19 }
20 }
21 });
22 ctx.body = company.jobs;
23 },
24 async create(ctx) {
25 const company = await strapi.db.query('api::company.company').findOne({
26 where: { representative: ctx.state.user.id }
27 });
28 if(company){
29
30 let job = await strapi.entityService.create('api::job.job', {
31 data: {
32 ...ctx.request.body.data,
33 company: company.id
34 }
35 });
36
37 ctx.body = job
38
39 }else{
40
41 ctx.body = {
42 success: false,
43 message: "Company not found!"};
44 }
45 },
46 async delete(ctx) {
47 const company = await strapi.db.query('api::company.company').findOne({
48 where: { representative: ctx.state.user.id }
49 });
50 const target_job = await strapi.db.query('api::job.job').findOne({
51 where: { company: company.id, id: ctx.request.params.id }
52 });
53 if (target_job != null) {
54 const job = await strapi.entityService.delete('api::job.job', target_job.id);
55 ctx.body = target_job
56 } else {
57 ctx.body = {
58 success: false
59 }
60 }
61 }
62 }));
The asynchronous function named myJobs utilizes the query engine api to find a company whose representative is the user saved in the connection. We then preload the companies jobs, each job’s applications and their respective applicant details. The next function called create is an override of the default create function. Before a job is created, we first check if the current user is associated with a company. If so we create a job associated with that company. On the delete function we perform the same check before we permanently remove a job record.
Custom Routing Since we added the myJobs function in the controller, we need to map it to a route. This will enable the function to be trigger when an HTTP request is made to the route. In the job directory, under the routes folder create two files as shown below. The first file will be used to plugin in our custom route to the job collection router. Since code is run sequentially, our custom route will be loaded first then the default routes afterwards.
Add the code below in the job-1.js
file. The code defines an endpoint at localhost:1337/api/jobs/mine. The endpoint is a GET HTTP request that will trigger the myJobs function we had created in the controller.
1 //job-1.js
2 module.exports = {
3 routes: [
4 {
5 method: 'GET',
6 path: '/jobs/mine',
7 handler: 'job.myJobs'
8 }
9 ]
10 }
The job-2.js file will contains the default collection router function.
1 //job-2.js
2 const { createCoreRouter } = require('@strapi/strapi').factories;
3 module.exports = createCoreRouter('api::job.job');
We are going to automatically link the user in the connection to a company. This is happen after signing up. We will use the entity service API to upload a file to the record being created. The file in the company context is the company’s logo. View the code below to see how it will be implemented.
1 //./src/api/company/controllers/company.js
2 const { createCoreController } = require('@strapi/strapi').factories;
3 module.exports = createCoreController('api::company.company', ({ strapi }) => ({
4 async create(ctx) {
5 const files = ctx.request.files;
6 let company = await strapi.entityService.create('api::company.company', {
7 data: {
8 ...ctx.request.body,
9 representative: ctx.state.user.id
10 },
11 files
12 });
13 ctx.body = company;
14 },
15 async myProfile(ctx) {
16 const company = await strapi.db.query('api::company.company').findOne({
17 where: { representative: ctx.state.user.id },
18 populate: { logo: true },
19 });
20 ctx.body = company;
21 }
22 }));
The myProfile function is a custom function that will load the company’s profile on a dedicate route. It queries the collection with the current user’s details then preloads the company’s logo.
The function is mapped to a GET HTTP request as shown below. The endpoint is localhost:1337/api/companies/me
.
1 //./src/api/company/routes/company-1.js
2 module.exports = {
3 routes: [{
4 method: 'GET',
5 path: '/companies/me',
6 handler: 'company.myProfile',
7 }]
8 }
In this collection, we want to be able to process resume uploads and link them to the current user. We also want to initiate a conversation once an application has been updated to the status ‘accepted’ by the company’s representative. We will also implement a custom controller function that will enable a user to load all their applications. The function will be named mine
and it will use the query engine API to fetch all applications associated with the current user. It will also preload the job the applied for, the file they attached to the application, the name of the company the created the job and its logo.
1 //./src/api/application/controllers/application.js
2 const { createCoreController } = require('@strapi/strapi').factories;
3 module.exports = createCoreController('api::application.application', ({ strapi }) => ({
4 async create(ctx) {
5 const files = ctx.request.files;
6 let application = await strapi.entityService.create('api::application.application', {
7 data: {
8 ...ctx.request.body,
9 applicant: ctx.state.user.id
10 },
11 files
12 });
13 ctx.body = application
14 },
15 async update(ctx) {
16 const updated_application = await strapi.entityService.update('api::application.application', ctx.request.params.id, {
17 data: ctx.request.body
18 });
19 if (ctx.request.body.status == "accepted") {
20 let application = await strapi.entityService.create('api::message.message', {
21 data: {
22 room: `${ctx.state.user.username}_${ctx.request.body.job}_${ctx.request.body.applicant_username}`,
23 texts: [
24 {
25 source: 0,
26 text: "Application accepted",
27 id: Date.now()
28 }
29 ]
30 }
31 });
32 }
33 ctx.body = updated_application;
34 },
35 async mine(ctx) {
36 const applications = await strapi.db.query('api::application.application').findMany({
37 where: { applicant: ctx.state.user.id },
38 orderBy: { id: 'DESC' },
39 populate: {
40 job: {
41 populate: {
42 company: {
43 populate: {
44 logo: true
45 }
46 }
47 }
48 },
49 cv: true
50 },
51 });
52 ctx.body = applications;
53 }
54 }));
The custom function named mine is linked to the endpoint localhost:1337/api/applications/mine through a GET HTTP request. Like before, custom routes are loaded first then default collection routes.
1 //./src/api/application/routes/routes-1.js
2 module.exports = {
3 routes: [
4 {
5 method: 'GET',
6 path: '/applications/mine',
7 handler: 'application.mine',
8 }
9 ]
10 }
We could confirm whether our routes have been recognized by running the command below on the command line (terminal/cmd).
npm run strapi routes:list
# OR
yarn strapi routes:list
The admin interface to check whether our custom routes have been loaded. We will ensure that all our custom routes are accessed by authenticated user only.
Our job board application will have a messaging feature which will allow an applicant and a company representative to communicate. This feature will rely on the socket.IO server library which will provide an event driven bi-directional communication model allows us to build this real time messaging feature. Use the command below to install the library within our Strapi project’s package.json file.
npm install socket.io
# OR
yarn add socket.io
The socket needs to be instantiated before the server starts. We will attach it to our instance’s address and port number. Open ./src/index.js
, the file contains functions that run before the Strapi application is started. We are going to add our code within the bootstrap function block. We will not specify the Cross-Origin Resource Sharing (CORS) object so that connections can be made from any address and port. If you are expecting a connection from a single know source it is advisable to add its address. Limiting CORS addresses helps prevent unauthorized access to sensitive data and resources on the server. Only allowed domains are allowed to make cross-origin requests to the server.
Within the bootstrap function block, we directly call and initialize the socket.io library we installed. We then specify the request type that will be used by the HTTP long polling transport method. Before we allow a client to connect, we verify their authentication token first. The token contains the user’s id as its payload. Therefore decoding the token will output an object that contains the which will allow us to fetch the user’s data from the database using the entityService API. When a user is successfully fetched from the db, we save their details on the socket connection otherwise the socket connection will fail.
1 //./src/index.js
2 module.exports = {
3
4 bootstrap({ strapi }) {
5 let interval;
6 let io = require('socket.io')(strapi.server.httpServer, {
7 cors: {
8 origin: "*",
9 methods: ["GET", "POST"]
10 }
11 });
12
13 io.use(async (socket, next) => {
14 try {
15
16 //Socket Authentication
17 const result = await strapi.plugins['users-permissions'].services.jwt.verify(socket.handshake.auth.token);
18 const user = await strapi.entityService.findOne('plugin::users-permissions.user', result.id, {
19 fields: ['first_name', 'last_name', 'email', 'id', 'username', 'phone_number']
20 });
21 //Save the User to the socket connection
22 socket.user = user;
23 next();
24 } catch (error) {
25 console.log(error);
26 }
27
28 }).on('connection', function (socket) {
29 if (interval) {
30 clearInterval(interval);
31 }
32 interval = setInterval(async () => {
33 try {
34 const entries = await strapi.entityService.findMany('api::message.message', {
35 filters: {
36 $or: [
37 {
38 room: {
39 $endsWith: `_${socket.user.username}`,
40 }
41 },
42 {
43 room: {
44 $startsWith: `${socket.user.username}_`,
45 }
46 },
47 ],
48 },
49 sort: { createdAt: 'DESC' },
50 populate: { texts: true },
51 });
52
53 io.emit('messages', JSON.stringify({ "payload": entries })
54 ); // This will emit the event to all connected sockets
55
56
57 } catch (error) {
58 console.log(error);
59 }
60
61 }, 2500);
62
63 socket.on('send_message', async (sent_message) => {
64
65 const message = await strapi.db.query('api::message.message').findOne({
66 where: { room: sent_message.room },
67 populate: { texts: true },
68 });
69
70
71 let new_text = message.texts;
72 new_text.push(
73 {
74 "text": sent_message.text,
75 "source": socket.user.id,
76 "created": new Date().getTime(),
77 "id": generateUUID()
78 }
79 )
80
81 const entry = await strapi.db.query('api::message.message').update({
82 where: { room: sent_message.room },
83 data: {
84 texts: new_text,
85 },
86 });
87
88 io.to(sent_message.room).emit("room_messages", JSON.stringify({ message: entry }));
89 });
90
91 socket.on('join_room', async (sent_message) => {
92 socket.join(sent_message.room);
93 const entry = await strapi.db.query('api::message.message').findOne({
94 where: { room: sent_message.room },
95 populate: { texts: true },
96 });
97
98 io.to(sent_message.room).emit("room_messages", JSON.stringify({ message: entry }));
99
100 });
101
102 socket.on('exit_room', (message) => {
103 socket.leave(message.room);
104 });
105
106 socket.on('disconnect', () => {
107 clearInterval(interval);
108 console.log('user disconnected');
109 });
110
111 });
112 return strapi
113 },
114 };
Within the connection event listener, we defined a set of functions that will be triggered when specific events are made by the client. Since we are build a real time chat, we defined an event named ‘join_room’ which will allow two clients to join and share a room and the ‘send_message’ event which will be responsible for allowing clients to send messages to a specific room.
Within the same socket connection event function block we broadcast all available messages after every 2.5 seconds using the setInterval function. We use the entityService API to filter the records based on the client’s username. Since this event is broadcasted to every device the filtering enables us to only show the most relevant messages to the client. Within the disconnect event, we clear the interval to help prevent unnecessary resource usage and free up server instance memory.
We need to have Xcode installed on our development machine in order to get started using SwiftUI. Since SwiftUI only target Apple platform, we need to use a Mac. This will enable us to emulate devices such as iPhones, iPads and Apple Watches. You can download Xcode through Mac’s App Store or its website.
Once you’ve successfully installed it and opened it, you’ll be greated by the screen below. Click create new Xcode Project, Select iOS App and give it a product name of JobBoard
We are going to install socket io’s swift client package into our iOS project. To get started, click the file button on the toolbar of Xcode’s window. Then click add new packages from the list that pops up.
The package we are going to install is open source and is actively maintained by developers at socket io. We are going to use the package’s GitHub repository link to direct Xcode on where it should fetch the source code for the package.
Within the modal’s search bar paste socket io’s github repository link and press enter, Xcode will try resolve the address and a README.md file will be rendered once complete. Click add package on the bottom right of the modal to complete the package installation.
We are going to create classes that will be used to make HTTP and socket requests. Create a folder named Helpers
and create the following files within it NetworkService, AuthPersistor, NetworkModels and SocketService. These files will contain the .swift file extension. By Default, Xcode does not show the file extension within the projects file structure but will show the file type logo before the file’s name.
Within the Helpers folder we create a file that will be used to save the Jwt token we receive from the strapi instance during authentication. We will be using iOS keychain feature to handle persistency. Keychain is mainly used to store sensitive information such as passwords and authentication keys. Keychain is an encrypted database that is used by apple devices to store passwords. The database is locked when the device is locked and unlocked when the device is unlocked. This will ensure that the token we received is safe and cannot be accessed by unauthorised parties.
We are going to create a class that will contain functions that will help us access Keychain. We will mark it as final so that it can't be overridden or modified. We will then initialise a static class constructor named standard. The static keyword ensures that the variable belongs to the type rather than a specific instance of that type. This means that every instance of that class will share the object named standard rather than have each one define their own.
1 //Helpers/AuthPersistor.swift
2 import Foundation
3 import Combine
4
5 final class KeychainHelper {
6
7 static let standard = KeychainHelper()
8 private init() {}
9
10 func save(_ data: Data, service: String, account: String) {
11
12 let query = [
13 kSecValueData: data,
14 kSecAttrService: service,
15 kSecAttrAccount: account,
16 kSecClass: kSecClassGenericPassword
17 ] as CFDictionary
18
19 // Add data in query to keychain
20 let status = SecItemAdd(query, nil)
21
22 if status == errSecDuplicateItem {
23 // Item already exist, thus update it.
24 let query = [
25 kSecAttrService: service,
26 kSecAttrAccount: account,
27 kSecClass: kSecClassGenericPassword,
28 ] as CFDictionary
29
30 let attributesToUpdate = [kSecValueData: data] as CFDictionary
31
32 // Update existing item
33 SecItemUpdate(query, attributesToUpdate)
34 }
35 }
36
37 func read(service: String, account: String) -> Data? {
38
39 let query = [
40 kSecAttrService: service,
41 kSecAttrAccount: account,
42 kSecClass: kSecClassGenericPassword,
43 kSecReturnData: true
44 ] as CFDictionary
45 var result: AnyObject?
46 SecItemCopyMatching(query, &result)
47 return (result as? Data)
48 }
49
50 func delete(service: String, account: String) {
51 let query = [
52 kSecAttrService: service,
53 kSecAttrAccount: account,
54 kSecClass: kSecClassGenericPassword,
55 ] as CFDictionary
56 // Delete item from keychain
57 SecItemDelete(query)
58 }
59 }
60
61 extension KeychainHelper {
62 func save<T>(_ item: T, service: String, account: String) where T : Codable {
63 do {
64 // Encode as JSON data and save in keychain
65 let data = try JSONEncoder().encode(item)
66 save(data, service: service, account: account)
67 } catch {
68 assertionFailure("Fail to encode item for keychain: \(error)")
69 }
70 }
71
72 func read<T>(service: String, account: String, type: T.Type) -> T? where T : Codable {
73 // Read item data from keychain
74 guard let data = read(service: service, account: account) else {
75 return nil
76 }
77 // Decode JSON data to object
78 do {
79 let item = try JSONDecoder().decode(type, from: data)
80 return item
81 } catch {
82 assertionFailure("Fail to decode item for keychain: \(error)")
83 return nil
84 }
85 }
86 }
The class contains three functions namely read, delete and save. Each function relies on CFDictonaries to represent data that can be stored with the Keychain database. CFDictonaries are key-value pairs similar to swift’s NSDictionary. The save function is used to upset items. We added a check to handle cases where the key already exists. Values if existing keys will be updated.
We then extended the class’ functionality to cater for JSON encodeable and decodeable objects using the extension keyword. The keyword adds new functionality to existing classes, structures, enumerations and protocol types. It is mainly used to extend data types which we don't have direct access to. For the added functionality to work flawlessly, the object passed to the read and save functions must conform to the codable protocol which transform the objects to JSON format which can be stored easily in the Keychain Database. We added an error handling exception that will prevent our application from crashing if the object passed does not conform to the said protocol.
We will be using data models to structure the data received from the strapi server. Within the project directory, create a new folder named Models and create the following swift files: Job, JobApplication, Company, Message and User.
The Job file sets up a struct that conforms to both the codable and identifiable protocols. The struct named job contains two inializers one of which will be used to decode JSON data from strapi to build and instance of the struct using the data retrived. The initlizer contains JSON decoder keys that will be used to fetch deeply embedded JSON objects.
1 //Models/Job.swift
2 struct Job : Codable, Identifiable{
3
4 var id : Int
5 var name : String
6 var description : String
7 var company : Company? = nil
8 var type : String //Contract/Long Term/Short Term/Internship/Consultancy
9 var environment : String //Remote/Semi-remote/In-Office
10 var status : String
11
12 init(id: Int, name : String, description: String, type: String, environment: String, status: String){
13 self.id = id
14 self.name = name
15 self.description = description
16 self.type = type
17 self.environment = environment
18 self.status = status
19 }
20
21 private enum JobDataKeys: String, CodingKey {
22 case id = "id",attributes = "attributes"
23 enum AttributeKeys : String, CodingKey {
24 case name = "name",
25 description = "description",
26 company = "company",
27 type="type",
28 environment = "environment",
29 status = "status"
30
31 enum CompanyKey : String, CodingKey{
32 case data = "data"
33 enum CompanyDataKeys : String, CodingKey {
34 case id = "id", attributes = "attributes"
35 enum CompanyDataAttributesKeys : String, CodingKey{
36 case address = "address",
37 bio = "bio",
38 category = "category",
39 email = "email",
40 name = "name",
41 phone = "phone",
42 logo = "logo"
43
44 enum CompanyLogoKey : String, CodingKey{
45 case data = "data"
46 enum CompanyLogoAttributes : String, CodingKey {
47 case attributes = "attributes"
48 enum CompanyLogoAttributeKeys : String, CodingKey {
49 case url = "url", formats = "formats"
50 enum CompanyLogoAttributeFormatsKeys : String, CodingKey {
51 case large = "large",
52 medium = "medium",
53 small = "small",
54 thumbnail = "thumbnail",
55 url = "url"
56 enum CompanyLogoFormartsLarge: String, CodingKey{case url = "url" }
57 enum CompanyLogoFormartsThumbnail: String, CodingKey{case url = "url"}
58 enum CompanyLogoFormartsSmall: String, CodingKey{case url = "url"}
59 enum CompanyLogoFormartsMedium: String, CodingKey{case url = "url"}
60 }
61 }
62 }
63 }
64 }
65 }
66 }
67 }
68 }
69 }
The Company.swift file defines the struct that defines how a company’s information will be structured within our Job Board Application. The file below shows how the struct is defined. Both the Company and Company logo structs conform to the codable protocols which ensures that they can be transform to and from JSON format.
1 //Company.swift
2 import Foundation
3 struct Company : Codable{
4 var id: Int
5 var name : String
6 var phone : String
7 var email : String
8 var address : String
9 var category : String //Tech/Pharma/Transport/NGO/Finance
10 var bio: String
11 var logo : CompanyLogo?
12 init(id: Int, name: String, phone: String, email: String, address: String, category: String, bio: String, logo: CompanyLogo?) {
13 self.id = id
14 self.name = name
15 self.phone = phone
16 self.email = email
17 self.address = address
18 self.category = category
19 self.bio = bio
20 self.logo = logo
21 }
22 }
23
24 struct CompanyLogo : Codable{
25 var url : String
26 var thumbnail : String
27 var small : String
28 var medium : String
29 var large : String
30 init(url: String, thumbnail: String, small: String, medium: String, large: String) {
31 self.url = url
32 self.thumbnail = thumbnail
33 self.small = small
34 self.medium = medium
35 self.large = large
36 }
37 }
This file defines the struct that will be used to represent our user’s information. User details can be converted to and from JSON since the struct conforms to the Codable protocol. The file also contains an AuthState struct which will listen to changes in authentication states by utilising swift’s PubSub system. The struct also utilizes the KeyChainHelper class we created. When a user is authenticated, their details are persisted in the Key chain database. We are storing it there because the jwt token is included within the user struct.
1 //Models/User.swift
2 import Foundation
3 import Combine
4
5 struct User: Codable{
6 var username : String
7 var id : Int
8 var phone_number : String
9 var email : String
10 var first_name : String
11 var last_name : String
12 var token : String
13 var profile : ProfileImage? = nil
14 var role : Role? = nil
15 }
16
17 struct ProfileImage : Codable {
18 var small : String = ""
19 var medium : String = ""
20 var large : String = ""
21 var thumbnail : String = ""
22 var url : String = ""
23 }
24
25 struct Role: Codable{
26 var name : String
27 var description: String
28 private enum RoleKeys : String, CodingKey {
29 case name = "name", description = "description"
30 }
31 init(from decoder: Decoder) throws {
32 let container = try decoder.container(keyedBy: RoleKeys.self)
33 self.name = try container.decode(String.self, forKey: .name)
34 self.description = try container.decode(String.self, forKey: .description)
35 }
36 }
37
38 struct AuthState{
39 static let Authenticated = PassthroughSubject<Bool, Never>()
40 static let Company = PassthroughSubject<Bool, Never>()
41 static func IsAuthenticated() -> Bool {
42 let user = KeychainHelper.standard.read(
43 service: "strapi_job_authentication_service",
44 account: "strapi_job_app",
45 type: User.self)
46 NetworkService.current_user = user
47 return user != nil
48 }
49
50 static func IsCompany() -> Bool {
51 let company = KeychainHelper.standard.read(
52 service: "strapi_job_company_service",
53 account: "strapi_job_app",
54 type: MyApplicationJobCompany.self)
55 NetworkService.company = company
56 return company != nil
57 }
58 }
Since SwiftUI is a statically typed language, we need to declare variable data types before compilation. We also need to parse the data from the strapi server into a swiftUI data types. We will create a file that will handle JSON parsing after successful network call. The file will contain structs that could be used by POST and GET request. It also contains file uploader structs for various file types.
1 //NetworkModels.swift
2 import Foundation
3 import PhotosUI
4 import PDFKit
5
6 struct BulkJobServerResponse: Decodable {
7 var data : [Job]
8 enum DataKeys: CodingKey {
9 case data
10 }
11 init(from decoder: Decoder) throws {
12 let container = try decoder.container(keyedBy: DataKeys.self)
13 self.data = try container.decode([Job].self, forKey: .data)
14 }
15 }
16
17 struct AuthenticationResponse : Codable{
18
19 var user : User
20 enum AuthResponseKeys: String, CodingKey {
21 case jwt = "jwt", user = "user"
22 enum UserDetailsKeys : String, CodingKey {
23 case id = "id", username = "username", email = "email", first_name = "first_name", last_name = "last_name", phone_number = "phone_number"
24 }
25 }
26
27 init(from decoder: Decoder) throws {
28 let authReponseContainer = try decoder.container(keyedBy: AuthResponseKeys.self)
29 let userDetailsContainer = try authReponseContainer.nestedContainer(keyedBy: AuthResponseKeys.UserDetailsKeys.self, forKey: .user)
30 let id = try userDetailsContainer.decode(Int.self, forKey: .id)
31 let phone_number = try userDetailsContainer.decode(String.self, forKey: .phone_number)
32 let username = try userDetailsContainer.decode(String.self, forKey: .username)
33 let first_name = try userDetailsContainer.decode(String.self, forKey: .first_name)
34 let last_name = try userDetailsContainer.decode(String.self, forKey: .last_name)
35 let email = try userDetailsContainer.decode(String.self, forKey: .email)
36 let jwt = try authReponseContainer.decode(String.self, forKey: .jwt)
37 self.user = User(username: username, id: id, phone_number: phone_number, email: email, first_name: first_name, last_name: last_name, token: jwt )
38 }
39 }
40
41 struct UploadImage {
42 let key: String
43 let filename: String
44 let data: Data
45 let mimeType: String
46 init?(withImage image: UIImage, forKey key: String) {
47 self.key = key
48 self.mimeType = "image/jpeg"
49 self.filename = "imagefile.jpg"
50 guard let data = image.jpegData(compressionQuality: 0.7) else { return nil }
51 self.data = data
52 }
53 }
54
55 struct UploadPDF {
56 let key: String
57 let filename: String
58 let data: Data
59 let mimeType: String
60 init?(withPDF pdfdoc: PDFDocument, forKey key: String) {
61 self.key = key
62 self.mimeType = "application/pdf"
63 self.filename = "document.pdf"
64 self.data = pdfdoc.dataRepresentation()!
65 }
66 }
There are two types of users, a normal user and a company user. A company user has a company profile associated with it. They have the capability to add new jobs and view job applications of the jobs they created.
When the company user type is selected, we immediately create a company and associate it with the newly created user. They can then modify the company details on the profile tab once authenticated.
1 //Register Function
2 func register(first_name:String, last_name: String, username: String,email:String, phone:String, password: String, completion: @escaping (User?) -> () ){
3
4 guard let url = URL(string: "\(authentication_url)/local/register") else {
5 completion(nil)
6 fatalError("Missing URL")
7 }
8
9 var urlRequest = URLRequest(url: url)
10 urlRequest.httpMethod = "POST"
11 urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
12 let parameters: [String: Any] = [
13 "username": username,
14 "password": password,
15 "email" : email,
16 "first_name": first_name,
17 "last_name": last_name,
18 "phone_number": phone
19 ]
20 do {
21 // convert parameters to Data and assign dictionary to httpBody of request
22 urlRequest.httpBody = try JSONSerialization.data(withJSONObject: parameters)
23 print(urlRequest)
24 } catch let error {
25 assertionFailure(error.localizedDescription)
26 completion(nil)
27 return
28 }
29
30 let dataTask = URLSession.shared.dataTask(with: urlRequest) { (data, response, error) in
31
32 if let error = error {
33 print("Request error: ", error)
34 completion(nil)
35 return
36 }
37 // ensure there is data returned
38 guard let responseData = data else {
39 assertionFailure("nil Data received from the server")
40 completion(nil)
41 return
42 }
43
44 do {
45
46 let loaded_user = try JSONDecoder().decode(AuthenticationResponse.self, from: responseData)
47
48 KeychainHelper.standard.save(loaded_user.user, service: "strapi_job_authentication_service",account: "strapi_job_app")
49
50 NetworkService.current_user = loaded_user.user
51 completion(loaded_user.user)
52
53 } catch let DecodingError.dataCorrupted(context) {
54 print(context)
55 completion(nil)
56 } catch let DecodingError.keyNotFound(key, context) {
57 print("Key '\(key)' not found:", context.debugDescription)
58 print("codingPath:", context.codingPath)
59 completion(nil)
60 } catch let DecodingError.valueNotFound(value, context) {
61 print("Value '\(value)' not found:", context.debugDescription)
62 print("codingPath:", context.codingPath)
63 completion(nil)
64 } catch let DecodingError.typeMismatch(type, context) {
65 print("Type '\(type)' mismatch:", context.debugDescription)
66 print("codingPath:", context.codingPath)
67 completion(nil)
68 } catch let error {
69 assertionFailure(error.localizedDescription)
70 completion(nil)
71 }
72 }
73 dataTask.resume()
74 }
All Network Requests call functions are defined in the NetworkService.swift file. Each view instantiates the network service object then calls the appropriate function within the .onAppear modifier which is an view instance method that we are utilising to fetch data when a view has been loaded on the device screen.
This part manages everything associated with a company. It allows a user to update their company profile, view, approve and decline applications, create, view, update and delete Job posts.
To access this context, the user must navigate to the profiles tab and he/she will see the company profile button just above the log out button. This button is not available to user’s without a company associated to their user account.
This section contains functionalities to do CRUD actions on the job collection we had created. Since we had overridden the job collection, our code checks whether a certain job is associated with the company and user. This prevents unauthorised people from updating and deleting jobs that are not associated to a company that is liked to their user profile.
We need to consume the socket connection we had created using Socket IO on the strapi server. Below is a simple swift client side class definition that includes authentication. The class is used to facilitate real time messaging between the applicant and company representative. It also contains function which assist us in various socket io functionalities for example listening to broadcasts, joining rooms and emitting events.
1 //SocketService.swift
2 final class SocketService : ObservableObject{
3 private var manager = SocketManager(socketURL: URL(string: "ws://127.0.0.1:1337")!, config: [ .compress])
4 @Published var socket_messages : [SocketMessage] = []
5 @Published var room : SocketMessage = SocketMessage(id: 0, room: "", texts: [])
6 let socket : SocketIOClient
7 init(){
8 self.socket = manager.defaultSocket
9 self.socket.on(clientEvent: .connect, callback: {data, ack in print("Connected") })
10 self.socket.on("messages") {data, ack in
11 guard let cur = data[0] as? String else { return }
12 let jsonObjectData = cur.data(using: .utf8)!
13 do {
14 let candidate = try JSONDecoder().decode(
15 MM.self,
16 from: jsonObjectData
17 )
18 self.socket_messages = candidate.payload
19 } catch let DecodingError.dataCorrupted(context) {
20 print(context)
21 self.socket_messages = []
22 } catch let DecodingError.keyNotFound(key, context) {
23 print("Key '\(key)' not found:", context.debugDescription)
24 print("codingPath:", context.codingPath)
25 self.socket_messages = []
26 } catch let DecodingError.valueNotFound(value, context) {
27 print("Value '\(value)' not found:", context.debugDescription)
28 print("codingPath:", context.codingPath)
29 self.socket_messages = []
30 } catch let DecodingError.typeMismatch(type, context) {
31 print("Type '\(type)' mismatch:", context.debugDescription)
32 print("codingPath:", context.codingPath)
33 self.socket_messages = []
34 } catch let error {
35 assertionFailure(error.localizedDescription)
36 self.socket_messages = []
37 }
38 }
39 self.socket.on("room_messages") {data, ack in
40 guard let cur = data[0] as? String else { return }
41 let jsonObjectData = cur.data(using: .utf8)!
42 do {
43 let room_details = try JSONDecoder().decode(SocketMessage.self,from: jsonObjectData)
44 self.room = room_details
45 } catch let DecodingError.dataCorrupted(context) {
46 print(context)
47 self.room = SocketMessage(id: 0, room: "", texts: [])
48 } catch let DecodingError.keyNotFound(key, context) {
49 print("Key '\(key)' not found:", context.debugDescription)
50 print("codingPath:", context.codingPath)
51 self.room = SocketMessage(id: 0, room: "", texts: [])
52 } catch let DecodingError.valueNotFound(value, context) {
53 print("Value '\(value)' not found:", context.debugDescription)
54 print("codingPath:", context.codingPath)
55 self.room = SocketMessage(id: 0, room: "", texts: [])
56 } catch let DecodingError.typeMismatch(type, context) {
57 print("Type '\(type)' mismatch:", context.debugDescription)
58 print("codingPath:", context.codingPath)
59 self.room = SocketMessage(id: 0, room: "", texts: [])
60 } catch let error {
61 assertionFailure(error.localizedDescription)
62 self.room = SocketMessage(id: 0, room: "", texts: [])
63 }
64 }
65 socket.connect(withPayload: ["token": NetworkService.current_user!.token])
66 }
67
68 func sendMesage(room_name: String, message : String) {
69 self.socket.emit("send_message", ["room": room_name, "text": message])
70 }
71
72 func joinRoom(room_name: String){
73 self.socket.emit("join_room", ["room": room_name])
74 }
75
76 func exitRoom(room_name: String){
77 self.socket.emit("exit_room", ["room": room_name])
78 }
79 }
The list of messages will be automatically refreshed and the other user will receive messages as soon as the company representative has pressed send. A conversation can only be initiated when a job application has been reviewed and accepted by the company representative. They can then reach out through our messaging feature.
We are consuming the socket connection within two views namely MessageView.swift and MessageDetail.swift. The code below shows how we structured MessageView.swift. Since the SocketService class conforms to the [**ObservableObject**](https://developer.apple.com/documentation/combine/observableobject)
protocol, we must use the property wrapper @StateObject every time we are creating its instance. Since we had set the server to broadcast messages after every 2.5 seconds, the UI will update once new changes are received.
1 //MessageView.swift
2 import SwiftUI
3 import SocketIO
4
5 struct MessageView: View {
6 @StateObject var service = SocketService()
7 static let tag: String? = "MessageView"
8 var body: some View {
9 NavigationView{
10 List(){
11 ForEach(service.socket_messages){message in
12 NavigationLink(destination: MessageDetailView(
13 socketMessage: message,
14 socket: service
15 )) {
16 Text(message.receiver)
17 }
18 }
19 }.navigationTitle("My Messages")
20 }
21 }
22 }
Once a user has logged in, they are greeted by a list of available jobs. They can then click their preferred job advert and upload their cvs. Immediately the view is loaded, we make a GET api call to the endpoint localhost:1337/api/jobs
using the NetworkService object within the onAppear view modifier. Since we had already logged in, the object will automatically append the token to the request’s headers.
1 //HomeView.swift
2 import SwiftUI
3
4 struct HomeView: View {
5 private var network = NetworkService()
6 static let tag: String? = "HomeView"
7 @State private var jobs : [Job] = []
8 var body: some View {
9 NavigationView{
10 List(jobs) { job in
11 NavigationLink {
12 DetailView(job: job)
13 } label: {
14 JobCard(job: job)
15 }
16 }.onAppear{
17 network.listJobs{fetched_jobs in
18 jobs = fetched_jobs
19 }
20 network.loadMyCompanyProfile{company_profile in
21 if company_profile != nil{
22 KeychainHelper.standard.save(company_profile, service: "strapi_job_company_service", account: "strapi_job_app")
23 NetworkService.company = company_profile
24 AuthState.Company.send(true)
25 }
26 }
27
28 }
29 .navigationTitle("Jobs")
30 }}
31 }
32
33 struct HomeView_Previews: PreviewProvider {
34 static var previews: some View {
35 HomeView()
36 }
37 }
Since we had set the current_user
variable as static while defining the NetworkService class, all instance of the class will have the jwt token which is stored in the current_user struct. The struct is updated using pubs during authentication and de-authentication.
In this tutorial we developed a Job Board application powered by Strapi and SwiftUI. We went through how to extend default collections functionalities. We added custom routes to strapi collections and attached Socket IO to the strapi server instance to allow realtime connections.
You can download the source code from the following repositories: