diff --git a/build.gradle.kts b/build.gradle.kts
index 8778adf..e46893f 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -22,6 +22,14 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-webmvc")
+
+ compileOnly("org.projectlombok:lombok:1.18.46")
+ annotationProcessor("org.projectlombok:lombok:1.18.46")
+
+ testCompileOnly("org.projectlombok:lombok:1.18.46")
+ testAnnotationProcessor("org.projectlombok:lombok:1.18.46")
+
+
runtimeOnly("com.h2database:h2")
testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test")
testImplementation("org.springframework.boot:spring-boot-starter-security-test")
diff --git a/docker-compose.yml b/docker-compose.yml
index e45a1c5..0db4bc3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,9 +4,13 @@ services:
ports:
- "8080:8080"
+ runner:
+ build: ./runner
+
frontend:
build: ./frontend
ports:
- "80:80"
depends_on:
- backend
+ - runner
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index f95c8d5..90c674a 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -10,6 +10,11 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
+ location /runner/ {
+ proxy_pass http://runner:5000/;
+ }
+
+
location / {
try_files $uri /index.html;
}
diff --git a/frontend/src/pages/ChallengePage.jsx b/frontend/src/pages/ChallengePage.jsx
index ec7a843..846edbc 100644
--- a/frontend/src/pages/ChallengePage.jsx
+++ b/frontend/src/pages/ChallengePage.jsx
@@ -3,8 +3,16 @@ import { useParams, Link } from 'react-router-dom'
import Editor from '@monaco-editor/react'
import api from '../api/axios'
+function sortKeys(val) {
+ if (Array.isArray(val)) return val.map(sortKeys)
+ if (val && typeof val === 'object') {
+ return Object.fromEntries(Object.keys(val).sort().map(k => [k, sortKeys(val[k])]))
+ }
+ return val
+}
+
function deepEqual(a, b) {
- return JSON.stringify(a) === JSON.stringify(b)
+ return JSON.stringify(sortKeys(a)) === JSON.stringify(sortKeys(b))
}
function sortBySource(arr) {
@@ -56,6 +64,17 @@ export default function ChallengePage() {
const [running, setRunning] = useState(false)
const [results, setResults] = useState(null)
const codeRef = useRef('')
+ const [language, setLanguage] = useState('javascript')
+ const availableLanguages = challenge ? ['javascript',
+ ...challenge.variants.map(v => v.language)]
+ : ['javascript']
+ useEffect(() => {
+ if (!challenge) return
+ const starterCode = language === 'javascript'
+ ? challenge.starterCode
+ : challenge.variants.find(v => v.language === language)?.starterCode ?? ''
+ codeRef.current = starterCode
+ }, [language, challenge])
useEffect(() => {
api.get(`/challenges/${id}`)
@@ -77,7 +96,27 @@ export default function ChallengePage() {
for (const tc of visibleTests) {
const input = JSON.parse(tc.inputJson)
const expected = JSON.parse(tc.expectedJson)
- const response = await runWorker(challenge.harnessCode, codeRef.current, input)
+ let response
+
+ if (language === 'javascript') {
+ response = await runWorker(challenge.harnessCode, codeRef.current, input)
+ } else {
+ const variant = challenge.variants.find(v => v.language === language)
+ try {
+ const res = await fetch('/runner/run', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ code: codeRef.current,
+ input,
+ harnessTemplate: variant.harnessTemplate,
+ }),
+ })
+ response = await res.json()
+ } catch (err) {
+ response = { ok: false, error: err.message }
+ }
+ }
if (!response.ok) {
testResults.push({ description: tc.description, pass: false, actual: null, expected, error: response.error })
@@ -97,7 +136,7 @@ export default function ChallengePage() {
code: codeRef.current,
status: 'PASS',
testResults: JSON.stringify(testResults),
- }).catch(() => {})
+ }).catch(() => { })
}
}
@@ -172,11 +211,42 @@ export default function ChallengePage() {
{/* Right column */}
+
+
+ {availableLanguages.map(lang => (
+
+ ))}
+
+
+
v.language === language)?.starterCode ?? ''
+ }
theme="vs-dark"
onChange={value => { codeRef.current = value ?? '' }}
options={{
diff --git a/runner/Dockerfile b/runner/Dockerfile
new file mode 100644
index 0000000..e295163
--- /dev/null
+++ b/runner/Dockerfile
@@ -0,0 +1,7 @@
+FROM python:3.12-slim
+WORKDIR /app
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+COPY app.py .
+EXPOSE 5000
+CMD ["python", "app.py"]
\ No newline at end of file
diff --git a/runner/app.py b/runner/app.py
new file mode 100644
index 0000000..ec4a6a8
--- /dev/null
+++ b/runner/app.py
@@ -0,0 +1,45 @@
+import json
+import os
+import subprocess
+import tempfile
+from flask import Flask, request, jsonify
+
+app = Flask(__name__)
+
+@app.route('/run', methods=['POST'])
+def run_code():
+ data = request.get_json()
+ code = data.get('code', '')
+ input_data = data.get('input', {})
+ harness_template = data.get('harnessTemplate', '')
+
+ script = harness_template.replace('{USER_CODE}', code)
+
+ tmp_path = None
+ try:
+ with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False, dir='/tmp') as f:
+ f.write(script)
+ tmp_path = f.name
+
+ result = subprocess.run(['python3', tmp_path],
+ input=json.dumps(input_data),
+ capture_output=True, text=True,
+ timeout=5, cwd='/tmp',)
+
+ if result.returncode != 0:
+ return jsonify({'ok': False, 'error': result.stderr.strip()})
+
+ return jsonify(json.loads(result.stdout))
+
+ except subprocess.TimeoutExpired:
+ return jsonify({'ok': False, 'error': 'Timeout (5s)'})
+ except json.JSONDecodeError:
+ return jsonify({'ok': False, 'error': 'Runner output was not valid JSON'})
+ except Exception as e:
+ return jsonify({'ok': False, 'error': str(e)})
+ finally:
+ if tmp_path and os.path.exists(tmp_path):
+ os.unlink(tmp_path)
+
+if __name__ == '__main__':
+ app.run(host='0.0.0.0', port=5000)
\ No newline at end of file
diff --git a/runner/requirements.txt b/runner/requirements.txt
new file mode 100644
index 0000000..dbcbaf7
--- /dev/null
+++ b/runner/requirements.txt
@@ -0,0 +1 @@
+flask==3.1.0
diff --git a/src/main/java/no/hvl/schemalab/DataSeeder.java b/src/main/java/no/hvl/schemalab/DataSeeder.java
index 2f1b946..13b5d55 100644
--- a/src/main/java/no/hvl/schemalab/DataSeeder.java
+++ b/src/main/java/no/hvl/schemalab/DataSeeder.java
@@ -3,26 +3,67 @@
import no.hvl.schemalab.model.*;
import no.hvl.schemalab.repository.AppUserRepository;
import no.hvl.schemalab.repository.ChallengeRepository;
+import no.hvl.schemalab.repository.ChallengeVariantRepository;
+
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
+import lombok.Data;
+
import java.util.List;
@Component
+@Data
public class DataSeeder implements CommandLineRunner {
private final ChallengeRepository challengeRepository;
+ private final ChallengeVariantRepository challengeVariantRepository;
private final AppUserRepository appUserRepository;
private final PasswordEncoder passwordEncoder;
- public DataSeeder(ChallengeRepository challengeRepository,
- AppUserRepository appUserRepository,
- PasswordEncoder passwordEncoder) {
- this.challengeRepository = challengeRepository;
- this.appUserRepository = appUserRepository;
- this.passwordEncoder = passwordEncoder;
- }
+ private final String MATCHING_HARNESS_PY = "import json, sys\n" + //
+ "input_data = json.loads(sys.stdin.read())\n" + //
+ "\n" + //
+ "{USER_CODE}\n" + //
+ "\n" + //
+ "try:\n" + //
+ " result = match_schemas(input_data['schemaA'], input_data['schemaB'])\n" + //
+ " print(json.dumps({\"ok\": True, \"result\": result}))\n" + //
+ "except Exception as e:\n" + //
+ " print(json.dumps({\"ok\": False, \"error\": str(e)}))";
+
+ private final String VERSIONING_HARNESS_PY = "import json, sys\n" + //
+ "input_data = json.loads(sys.stdin.read())\n" + //
+ "\n" + //
+ "{USER_CODE}\n" + //
+ "\n" + //
+ "try:\n" + //
+ " result = migrate(input_data['record'])\n" + //
+ " print(json.dumps({\"ok\": True, \"result\": result}))\n" + //
+ "except Exception as e:\n" + //
+ " print(json.dumps({\"ok\": False, \"error\": str(e)}))";
+
+ private final String MATCHING_TEMPLATE_PY = """
+ #
+ # Match fields from schemaA to schemaB.
+ # @param {Object} schemaA - JSON Schema object
+ # @param {Object} schemaB - JSON Schema object
+ # @returns [<{source: string, target: string}>]
+ #
+ def match_schemas(schema_a, schema_b):
+ return []
+ """;
+
+ private final String VERSIONING_TEMPLATE_PY = """
+ #
+ # Migrate a record from schema v1 to schema v2.
+ # @param record - A v1 record instance
+ # @returns {Object} - A v2 record instance
+ #
+ def migrate(record):
+ return {}
+ """;;
@Override
public void run(String... args) {
@@ -37,6 +78,18 @@ public void run(String... args) {
}
}
+ /// HELPER METHODS
+
+ private void addPythonVariant(Challenge challenge, String starterCode, String harnessTemplate) {
+ ChallengeVariant v = new ChallengeVariant();
+ v.setChallenge(challenge);
+ v.setLanguage("python");
+ v.setStarterCode(starterCode);
+ v.setHarnessTemplate(harnessTemplate);
+ challengeVariantRepository.save(v);
+ }
+
+ //
private void seedDevUser() {
if (appUserRepository.findByUsername("dev").isEmpty()) {
AppUser dev = new AppUser();
@@ -169,6 +222,8 @@ function matchSchemas(schemaA, schemaB) {
c.setTestCases(List.of(visible, hidden));
challengeRepository.save(c);
+
+ addPythonVariant(c, MATCHING_TEMPLATE_PY, MATCHING_HARNESS_PY);
}
private void seedChallenge2() {
@@ -264,6 +319,8 @@ record TestData(String fullName, String firstName, String lastName, String id, S
c.setTestCases(testCases);
challengeRepository.save(c);
+
+ addPythonVariant(c, VERSIONING_TEMPLATE_PY, VERSIONING_HARNESS_PY);
}
private void seedChallenge3() {
@@ -385,6 +442,8 @@ function matchSchemas(schemaA, schemaB) {
c.setTestCases(List.of(visible, hidden));
challengeRepository.save(c);
+
+ addPythonVariant(c, MATCHING_TEMPLATE_PY, MATCHING_HARNESS_PY);
}
private void seedChallenge4() {
@@ -485,6 +544,8 @@ record TestData(String id, String name, String street, String city, String posta
c.setTestCases(testCases);
challengeRepository.save(c);
+
+ addPythonVariant(c, VERSIONING_TEMPLATE_PY, VERSIONING_HARNESS_PY);
}
private void seedChallenge5() {
@@ -631,6 +692,8 @@ function matchSchemas(schemaA, schemaB) {
c.setTestCases(List.of(visible, hidden));
challengeRepository.save(c);
+
+ addPythonVariant(c, MATCHING_TEMPLATE_PY, MATCHING_HARNESS_PY);
}
private void seedChallenge6() {
@@ -730,5 +793,7 @@ record TestData(String empId, String fullName, String firstName, String lastName
c.setTestCases(testCases);
challengeRepository.save(c);
+
+ addPythonVariant(c, VERSIONING_TEMPLATE_PY, VERSIONING_HARNESS_PY);
}
}
diff --git a/src/main/java/no/hvl/schemalab/model/Challenge.java b/src/main/java/no/hvl/schemalab/model/Challenge.java
index b00f824..cf50ed0 100644
--- a/src/main/java/no/hvl/schemalab/model/Challenge.java
+++ b/src/main/java/no/hvl/schemalab/model/Challenge.java
@@ -1,10 +1,13 @@
package no.hvl.schemalab.model;
import jakarta.persistence.*;
+import lombok.Data;
+
import java.util.ArrayList;
import java.util.List;
@Entity
+@Data
public class Challenge {
@Id
@@ -32,75 +35,6 @@ public class Challenge {
@OneToMany(mappedBy = "challenge", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
private List testCases = new ArrayList<>();
- public Long getId() {
- return id;
- }
-
- public void setId(Long id) {
- this.id = id;
- }
-
- public String getTitle() {
- return title;
- }
-
- public void setTitle(String title) {
- this.title = title;
- }
-
- public String getDescription() {
- return description;
- }
-
- public void setDescription(String description) {
- this.description = description;
- }
-
- public String getDifficulty() {
- return difficulty;
- }
-
- public void setDifficulty(String difficulty) {
- this.difficulty = difficulty;
- }
-
- public String getType() {
- return type;
- }
-
- public void setType(String type) {
- this.type = type;
- }
-
- public String getStarterCode() {
- return starterCode;
- }
-
- public void setStarterCode(String starterCode) {
- this.starterCode = starterCode;
- }
-
- public String getHarnessCode() {
- return harnessCode;
- }
-
- public void setHarnessCode(String harnessCode) {
- this.harnessCode = harnessCode;
- }
-
- public List getSchemas() {
- return schemas;
- }
-
- public void setSchemas(List schemas) {
- this.schemas = schemas;
- }
-
- public List getTestCases() {
- return testCases;
- }
-
- public void setTestCases(List testCases) {
- this.testCases = testCases;
- }
+ @OneToMany(mappedBy = "challenge", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
+ private List variants = new ArrayList<>();
}
diff --git a/src/main/java/no/hvl/schemalab/model/ChallengeVariant.java b/src/main/java/no/hvl/schemalab/model/ChallengeVariant.java
new file mode 100644
index 0000000..9c98451
--- /dev/null
+++ b/src/main/java/no/hvl/schemalab/model/ChallengeVariant.java
@@ -0,0 +1,27 @@
+package no.hvl.schemalab.model;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import jakarta.persistence.*;
+import lombok.*;
+
+@Data
+@Entity
+public class ChallengeVariant {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @JsonIgnore
+ @ManyToOne
+ @JoinColumn(name = "challenge_id")
+ private Challenge challenge;
+
+ private String language;
+
+ @Column(columnDefinition = "TEXT")
+ private String starterCode;
+
+ @Column(columnDefinition = "TEXT")
+ private String harnessTemplate;
+}
diff --git a/src/main/java/no/hvl/schemalab/repository/ChallengeVariantRepository.java b/src/main/java/no/hvl/schemalab/repository/ChallengeVariantRepository.java
new file mode 100644
index 0000000..a60ceef
--- /dev/null
+++ b/src/main/java/no/hvl/schemalab/repository/ChallengeVariantRepository.java
@@ -0,0 +1,9 @@
+package no.hvl.schemalab.repository;
+
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import no.hvl.schemalab.model.ChallengeVariant;
+
+public interface ChallengeVariantRepository extends JpaRepository {
+
+}