Merge pull request #1 from lancatlin/server-justin

Add Quests and Sessions API
This commit is contained in:
Lin, Hung-Shin authored and GitHub committed 2023-07-20 21:55:52 +08:00
commit 4ffdb5d627
29 files changed
+2639 -46

No files matched your search

+2 -1
View File
@@ -1,2 +1,3 @@
vite.config.js
.gitignore
.gitignore
data
+1
View File
@@ -2,6 +2,7 @@ module.exports = {
root: true,
env: {
node: true,
jest: true,
},
extends: [
'plugin:vue/vue3-essential',
+1
View File
@@ -15,6 +15,7 @@ pids
*.pid
*.seed
*.pid.lock
data
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
+1
View File
@@ -0,0 +1 @@
data/*
+20
View File
@@ -1,5 +1,11 @@
# Linux Odyssey
## Requirements
- NodeJS
- Docker
- Yarn
## Installation
```
@@ -8,6 +14,12 @@ cd linux-odyssey
yarn install
```
## Create database
```
docker compose up -d
```
## Server
Source: `server/`
@@ -25,3 +37,11 @@ Source: `app/`
yarn app
# Open http://localhost:5173/
```
## CLI terminal client
Open server first, then
```
yarn cli
```
+9
View File
@@ -0,0 +1,9 @@
version: '3'
services:
db:
image: mongo
restart: always
ports:
- '27017:27017'
volumes:
- ./data:/data/db
+2 -1
View File
@@ -13,8 +13,9 @@
"scripts": {
"app": "yarn workspace app dev",
"server": "yarn workspace server dev",
"cli": "yarn workspace server cli",
"lint": "eslint --ext .js,.vue .",
"format": "prettier --write ."
"format": "prettier --ignore-path .prettierignore --write app server"
},
"devDependencies": {
"@vue/eslint-config-prettier": "^7.1.0",
-16
View File
@@ -1,16 +0,0 @@
import express from 'express'
import http from 'http'
import socketServer from './socket.js'
const port = 3000
const app = express()
const server = http.createServer(app)
socketServer(server)
app.get('/', (req, res) => {
res.send('Hello World!')
})
server.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`)
})
+10 -3
View File
@@ -4,16 +4,23 @@
"description": "Linux Odyssey Server",
"type": "module",
"scripts": {
"start": "node index.js",
"dev": "nodemon index.js"
"start": "node src/index.js",
"dev": "nodemon src/index.js",
"cli": "node src/client.js",
"test": "yarn node --experimental-vm-modules $(yarn bin jest)"
},
"dependencies": {
"dockerode": "^3.3.5",
"express": "^4.18.2",
"marked": "^5.1.1",
"mongoose": "^7.4.0",
"socket.io": "^4.7.1",
"socket.io-client": "^4.7.1"
"socket.io-client": "^4.7.1",
"swagger-ui-express": "^5.0.0",
"yaml": "^2.3.1"
},
"devDependencies": {
"jest": "^29.6.1",
"nodemon": "^3.0.1"
}
}
+26
View File
@@ -0,0 +1,26 @@
---
title: Hello World
order: 1
---
# Hello World
Welcome to your first quest! This quest will help you familiarize yourself with the terminal.
The terminal is a text-based interface that allows you to interact directly with your computer's operating system. You can perform various tasks by typing commands into the terminal and pressing `Enter` to execute them.
For your first task, you are going to use the `echo` command.
## Task 1: Using the `echo` Command
The `echo` command in Unix is used to display line of text/string that are passed as an argument. This is a built in command that is mostly used in shell scripts and batch files to output status text to the screen or a file.
Here is what you need to do:
1. Open your terminal.
2. Type `echo Hello World` and hit `Enter`.
3. You should see `Hello World` output to the terminal. This is because `echo` prints its arguments to the standard output, which is your terminal by default.
Congratulation! You've just run your first terminal command!
Stay tuned for the next quests where you'll delve deeper into more complex and powerful terminal commands.
@@ -0,0 +1,23 @@
import Quest from '../../models/quest.js'
export async function getQuests(req, res) {
const quests = await Quest.find({}).sort({ order: 1 })
res.json(
quests.map(({ name, title, order, _id }) => ({
_id,
name,
title,
order,
}))
)
}
export async function getQuestDetail(req, res) {
const { id } = req.params
const quest = await Quest.findById(id)
if (!quest) {
res.status(404).json({ message: 'Quest not found.' })
return
}
res.json(quest)
}
@@ -0,0 +1,72 @@
import Session from '../../models/session.js'
import Quest from '../../models/quest.js'
export async function getSessionList(req, res) {
try {
const query = { user: req.user.id }
if (req.query.quest_id) {
query.quest = req.query.quest_id
}
const sessions = await Session.find(query)
res.json(sessions)
} catch (err) {
console.error(err)
res.status(500).json({ message: err.message })
}
}
export async function createSession(req, res) {
const quest = await Quest.findById(req.body.quest_id)
if (!quest) {
res.status(400).json({ message: 'Quest not found.' })
return
}
const newSession = new Session({
user: req.user,
quest,
})
try {
const session = await newSession.save()
res.status(201).json(session)
} catch (err) {
res.status(400).json({ message: err.message })
}
}
export async function getSessionById(req, res) {
try {
const session = await Session.findOne({
_id: req.params.id,
user: req.user.id,
})
if (!session) {
res.status(404).json({ message: 'Session not found.' })
return
}
res.json(session)
} catch (err) {
res.status(500).json({ message: err.message })
}
}
export async function deleteSessionById(req, res) {
try {
const session = await Session.findOneAndDelete({
_id: req.params.id,
user: req.user.id,
})
if (!session) {
res.status(404).json({ message: 'Session not found.' })
return
}
res.status(204).end()
} catch (err) {
res.status(500).json({ message: err.message })
}
}
+15
View File
@@ -0,0 +1,15 @@
import { Router } from 'express'
import sessions from './sessions.js'
import quests from './quests.js'
import { authMiddleware } from '../../middleware/auth.js'
const router = Router()
router.get('/', (req, res) => {
res.send('Hello API!')
})
router.use('/sessions', authMiddleware, sessions)
router.use('/quests', quests)
export default router
+9
View File
@@ -0,0 +1,9 @@
import { Router } from 'express'
import * as questController from '../controllers/questController.js'
const router = Router()
router.get('/', questController.getQuests)
router.get('/:id', questController.getQuestDetail)
export default router
+11
View File
@@ -0,0 +1,11 @@
import { Router } from 'express'
import * as sessionController from '../controllers/sessionController.js'
const router = Router()
router.get('/', sessionController.getSessionList)
router.post('/', sessionController.createSession)
router.get('/:id', sessionController.getSessionById)
router.delete('/:id', sessionController.deleteSessionById)
export default router
@@ -1,5 +1,5 @@
import { Server } from 'socket.io'
import { getOrCreateContainer, attachContainer } from './docker.js'
import { getOrCreateContainer, attachContainer } from '../utils/docker.js'
export default (server) => {
const io = new Server(server)
File renamed without changes.
+10
View File
@@ -0,0 +1,10 @@
import mongoose from 'mongoose'
export default async function connectDB() {
try {
await mongoose.connect('mongodb://localhost:27017/odyssey-test')
console.log('Connected to MongoDB')
} catch (err) {
console.log(err)
}
}
+36
View File
@@ -0,0 +1,36 @@
import fs from 'fs/promises'
import express from 'express'
import http from 'http'
import YAML from 'yaml'
import swaggerUI from 'swagger-ui-express'
import socketServer from './api/socket.js'
import connectDB from './db.js'
import apiRouter from './api/routes/index.js'
import loadAndUpdateQuests from './utils/quest.js'
async function main() {
const port = 3000
const app = express()
const server = http.createServer(app)
socketServer(server)
await connectDB()
await loadAndUpdateQuests()
const file = await fs.readFile('./swagger.yaml', 'utf8')
const swaggerDocument = YAML.parse(file)
app.use('/api-docs', swaggerUI.serve, swaggerUI.setup(swaggerDocument))
app.use(express.json())
app.get('/', (req, res) => {
res.send('Hello World!')
})
app.use('/api/v1', apiRouter)
server.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`)
})
}
main().catch((err) => console.log(err))
+19
View File
@@ -0,0 +1,19 @@
/* eslint-disable import/prefer-default-export */
import User from '../models/user.js'
export async function authMiddleware(req, res, next) {
try {
let user = await User.findOne({ username: 'defaultUser' })
if (!user) {
user = new User({
username: 'defaultUser',
email: 'john@example.com',
})
await user.save()
}
req.user = user
next()
} catch (err) {
res.status(500).json({ message: err.message })
}
}
View File
Whitespace-only changes.
+13
View File
@@ -0,0 +1,13 @@
import { model, Schema } from 'mongoose'
const Quest = model(
'Quest',
new Schema({
_id: String,
title: String,
order: Number,
content: String,
})
)
export default Quest
+22
View File
@@ -0,0 +1,22 @@
import { model, Schema } from 'mongoose'
const Session = model(
'Session',
new Schema(
{
user: {
type: Schema.ObjectId,
ref: 'User',
},
quest: {
type: String,
ref: 'Quest',
},
containerId: String,
finishedAt: Date,
},
{ timestamps: true }
)
)
export default Session
+9
View File
@@ -0,0 +1,9 @@
import { model } from 'mongoose'
const User = model('User', {
username: String,
password: String,
email: String,
})
export default User
File renamed without changes.
+54
View File
@@ -0,0 +1,54 @@
import fs from 'fs/promises'
import path from 'path'
import { Marked } from 'marked'
import yaml from 'yaml'
import Quest from '../models/quest.js'
const marked = new Marked({
mangle: false,
headerIds: false,
})
const questDirectory = path.join(process.cwd(), 'quests')
export default async function loadAndUpdateQuests() {
const questNames = await fs.readdir(questDirectory)
const quests = Promise.all(
questNames.map(async (id) => {
const fullPath = path.join(questDirectory, id, 'README.md')
try {
// Read and parse the README.md file.
const markdown = await fs.readFile(fullPath, 'utf-8')
// Extract and parse YAML frontmatter.
const match = /---\n([\s\S]+?)\n---/.exec(markdown)
const metadata = yaml.parse(match[1])
const content = markdown.slice(match[0].length)
const htmlContent = marked.parse(content)
const { title, order } = metadata
return Quest.findByIdAndUpdate(
id,
{
_id: id,
title,
order,
content: htmlContent,
},
{
upsert: true,
}
)
} catch (error) {
console.error(`Error parsing quest ${id}:`, error)
throw error
}
})
)
return quests
}
+429
View File
@@ -0,0 +1,429 @@
openapi: 3.0.0
servers:
- url: /api/v1
info:
description: Linux Odyssey API
version: '1.0.0'
title: Session API
contact:
email: wancat@wancat.cc
license:
name: Apache 2.0
url: 'http://www.apache.org/licenses/LICENSE-2.0.html'
tags:
- name: admins
description: Secured Admin-only calls
- name: developers
description: Operations available to regular developers
paths:
/sessions:
get:
tags:
- signed-in
summary: list existing sessions of current user
security:
- BearerAuth: []
parameters:
- in: query
name: quest_id
description: find sessions by quest id
schema:
type: string
responses:
'200':
description: list of existing session
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/SessionSummary'
post:
summary: create a new session for current user
tags:
- signed-in
security:
- BearerAuth: []
requestBody:
description: session data
content:
application/json:
schema:
type: object
properties:
quest_id:
type: string
responses:
'201':
description: session created
content:
application/json:
schema:
$ref: '#/components/schemas/Session'
'400':
description: Invalid input
content:
application/json:
schema:
type: object
properties:
message:
type: string
/sessions/{session_id}:
get:
tags:
- signed-in
summary: get a specific session of current user by ID
security:
- BearerAuth: []
parameters:
- in: path
name: session_id
description: ID of the session to retrieve
required: true
schema:
type: string
format: objectId
example: 64b7a3a3c182b4826dd1aaa1
responses:
'200':
description: the session
content:
application/json:
schema:
$ref: '#/components/schemas/Session'
'404':
description: cannot find specific session
content:
application/json:
schema:
type: object
properties:
message:
type: string
delete:
tags:
- signed-in
summary: delete a specific session by ID
security:
- BearerAuth: []
parameters:
- in: path
name: session_id
required: true
schema:
type: string
format: objectId
example: 64b7a3a3c182b4826dd1aaa1
responses:
'204':
description: Session successfully deleted
'404':
description: Session not found
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: 'Session not found.'
'401':
description: Unauthorized
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: 'Unauthorized.'
/register:
post:
summary: Register a new user
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
username:
type: string
email:
type: string
password:
type: string
responses:
'201':
description: User created
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: User created successfully
'400':
description: Bad Request
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: Missing username, email or password
'409':
description: Conflict
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: User already exists
/login:
post:
summary: Log in a user
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
username:
type: string
password:
type: string
responses:
'200':
description: User logged in
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: Login successful
token:
type: string
description: Access token
'400':
description: Bad Request
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: Missing username or password
'401':
description: Unauthorized
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: Incorrect username or password
/profile:
get:
summary: Get the profile of current logged-in user
security:
- BearerAuth: []
responses:
'200':
description: Get user's profile
content:
application/json:
schema:
$ref: '#/components/schemas/User'
/quests:
get:
summary: Get quests list
responses:
'200':
description: Quests summary list
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/QuestSummary'
/quests/{quest_id}:
get:
summary: get a quest's detail by name
parameters:
- in: path
name: quest_id
description: name of the quest to retrieve
required: true
schema:
type: string
responses:
'200':
description: the quest
content:
application/json:
schema:
$ref: '#/components/schemas/Quest'
'404':
description: cannot find specific quest
content:
application/json:
schema:
type: object
properties:
message:
type: string
components:
schemas:
User:
type: object
properties:
_id:
type: string
format: objectId
example: 64b7a3a3c182b4826dd1aaa1
username:
type: string
example: 'alex'
email:
type: string
format: email
Quest:
type: object
properties:
_id:
type: string
example: helloworld
title:
type: string
example: Hello World
content:
type: string
format: html
example: '<h1>Hello World</h1>'
order:
type: number
QuestSummary:
type: object
properties:
_id:
type: string
example: helloworld
title:
type: string
example: Hello World
order:
type: number
Session:
type: object
properties:
_id:
type: string
format: objectId
example: 64b7a3a3c182b4826dd1aaa1
user:
$ref: '#/components/schemas/User'
quest:
$ref: '#/components/schemas/Quest'
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
finished_at:
type: string
format: date-time
terminals:
type: array
items:
type: string
graphs:
$ref: '#/components/schemas/TreeNode'
progress:
type: array
items:
$ref: '#/components/schemas/Progress'
hints:
type: array
items:
$ref: '#/components/schemas/Hint'
SessionSummary:
type: object
properties:
_id:
type: string
format: objectId
example: 64b7a3a3c182b4826dd1aaa1
quest:
$ref: '#/components/schemas/Quest'
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
finished_at:
type: string
format: date-time
TreeNode:
type: object
properties:
name:
type: string
display:
type: boolean
children:
type: array
items:
$ref: '#/components/schemas/TreeNode'
Progress:
type: object
properties:
name:
type: string
finished_at:
type: string
format: date-time
skipped_at:
type: string
format: date-time
Hint:
type: object
properties:
name:
type: string
content:
type: string
source:
type: string
securitySchemes:
BearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
@@ -0,0 +1,56 @@
import jest from 'jest'
import * as sessionController from '../../src/api/controllers/sessionController'
import Session from '../../src/models/session'
jest.mock('../../src/models/session')
describe('Session Controller', () => {
let mockReq
let mockRes
beforeEach(() => {
mockReq = {
user: { id: 'userid' },
body: {},
params: {},
}
mockRes = {
status: jest.fn().mockReturnThis(),
json: jest.fn().mockReturnThis(),
end: jest.fn().mockReturnThis(),
}
})
test('getSessionList returns a list of sessions', async () => {
Session.find.mockResolvedValue(['session1', 'session2'])
await sessionController.getSessionList(mockReq, mockRes)
expect(mockRes.json).toHaveBeenCalledWith(['session1', 'session2'])
})
test('createSession creates a new session', async () => {
mockReq.body.exercise_id = 'exerciseid'
const mockSession = { user: 'userid', exercise: 'exerciseid' }
Session.mockReturnValue(mockSession)
mockSession.save = jest.fn().mockResolvedValue('newsess')
await sessionController.createSession(mockReq, mockRes)
expect(mockRes.status).toHaveBeenCalledWith(201)
expect(mockRes.json).toHaveBeenCalledWith('newsess')
})
test('getSessionById returns the correct session', async () => {
mockReq.params.session_id = 'sessionid'
const mockSession = { user: 'userid', _id: 'sessionid' }
Session.findOne.mockResolvedValue(mockSession)
await sessionController.getSessionById(mockReq, mockRes)
expect(mockRes.json).toHaveBeenCalledWith(mockSession)
})
test('deleteSessionById deletes a session', async () => {
mockReq.params.session_id = 'sessionid'
const mockSession = { user: 'userid', _id: 'sessionid' }
Session.findOneAndDelete.mockResolvedValue(mockSession)
await sessionController.deleteSessionById(mockReq, mockRes)
expect(mockRes.status).toHaveBeenCalledWith(204)
expect(mockRes.end).toHaveBeenCalled()
})
})
+1788 -24
View File
File diff suppressed because it is too large. Load diff