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 { + +}