edu-format/src/com/jetbrains/edu/learning/json/CourseArchiveReader.kt (154 lines of code) (raw):
@file:JvmName("CourseArchiveReader")
package com.jetbrains.edu.learning.json
import com.fasterxml.jackson.core.JsonToken
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.InjectableValues
import com.fasterxml.jackson.databind.MapperFeature
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.databind.node.ObjectNode
import com.fasterxml.jackson.module.kotlin.treeToValue
import com.jetbrains.edu.learning.courseFormat.*
import com.jetbrains.edu.learning.courseFormat.EduFormatNames.COURSE_CONTENTS_FOLDER
import com.jetbrains.edu.learning.courseFormat.EduFormatNames.MARKETPLACE
import com.jetbrains.edu.learning.courseFormat.tasks.Task
import com.jetbrains.edu.learning.courseFormat.tasks.choice.ChoiceOption
import com.jetbrains.edu.learning.courseFormat.tasks.choice.ChoiceTask
import com.jetbrains.edu.learning.json.encrypt.EncryptionModule
import com.jetbrains.edu.learning.json.migration.*
import com.jetbrains.edu.learning.json.mixins.*
import com.jetbrains.edu.learning.json.mixins.JsonMixinNames.COURSE_TYPE
import com.jetbrains.edu.learning.json.mixins.JsonMixinNames.VERSION
import org.jetbrains.annotations.VisibleForTesting
import java.io.IOException
import java.io.Reader
import java.text.SimpleDateFormat
import java.util.*
private val LOG = logger<LocalEduCourseMixin>()
private class CourseJsonParsingException(message: String): Exception(message)
fun readCourseJson(reader: () -> Reader, fileContentsFactory: FileContentsFactory = EmtpyFileContentFactory): Course? {
return try {
val courseMapper = getCourseMapper(fileContentsFactory)
val isArchiveEncrypted = reader().use { currentReader ->
isArchiveEncrypted(currentReader, courseMapper)
}
courseMapper.configureCourseMapper(isArchiveEncrypted)
var courseNode = reader().use { currentReader ->
courseMapper.readTree(currentReader) as ObjectNode
}
courseNode = migrate(courseNode)
courseMapper.treeToValue(courseNode)
}
catch (e: IOException) {
LOG.severe("Failed to read course json: ${e.message}")
null
}
catch (e: CourseJsonParsingException) {
LOG.severe("Course json format error: ${e.message}")
null
}
}
@Throws(IOException::class, CourseJsonParsingException::class)
private fun isArchiveEncrypted(reader: Reader, courseMapper: ObjectMapper): Boolean {
val (version, courseType) = getFormatVersionAndCourseTypeFromJson(reader, courseMapper)
if (version >= 12) return true
return courseType == MARKETPLACE
}
@Throws(IOException::class, CourseJsonParsingException::class)
private fun getFormatVersionAndCourseTypeFromJson(
reader: Reader,
courseMapper: ObjectMapper
): Pair<Int, String?> = courseMapper.createParser(reader).use { parser ->
var version: Int? = null
var courseType: String? = null
// read start object token
parser.nextToken()
if (!parser.hasToken(JsonToken.START_OBJECT)) throw CourseJsonParsingException("No opening bracket in course.json")
// read object fields until the END_OBJECT
while (parser.nextToken() != null && !parser.hasToken(JsonToken.END_OBJECT)) {
// if the object is not finished, we expect a field name
if (!parser.hasToken(JsonToken.FIELD_NAME)) throw CourseJsonParsingException("Unexpected token ${parser.currentToken} in course.json")
when (parser.currentName) {
VERSION -> {
version = parser.nextIntValue(-1)
if (version == -1) throw CourseJsonParsingException("Course format version specified incorrectly")
}
COURSE_TYPE -> courseType = parser.nextTextValue()
else -> {
parser.nextToken()
parser.skipChildren()
}
}
if (version != null && courseType != null) break
}
version ?: throw CourseJsonParsingException("Format version is not specified")
return Pair(version, courseType)
}
fun migrate(jsonObject: ObjectNode): ObjectNode {
return migrate(jsonObject, JSON_FORMAT_VERSION)
}
@VisibleForTesting
fun migrate(node: ObjectNode, maxVersion: Int): ObjectNode {
var jsonObject = node
val jsonVersion = jsonObject.get(VERSION)
var version = jsonVersion?.asInt() ?: 1
while (version < maxVersion) {
var converter: JsonLocalCourseConverter? = null
when (version) {
6 -> converter = ToSeventhVersionLocalCourseConverter()
7 -> converter = To8VersionLocalCourseConverter()
8 -> converter = To9VersionLocalCourseConverter()
9 -> converter = To10VersionLocalCourseConverter()
10 -> converter = To11VersionLocalCourseConverter()
11 -> converter = To12VersionLocalCourseConverter()
21 -> converter = To22VersionLocalCourseConverter()
}
if (converter != null) {
jsonObject = converter.convert(jsonObject)
}
version++
}
return jsonObject
}
fun getCourseMapper(fileContentsFactory: FileContentsFactory): ObjectMapper {
return JsonMapper.builder()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
.disable(MapperFeature.AUTO_DETECT_FIELDS)
.disable(MapperFeature.AUTO_DETECT_GETTERS)
.disable(MapperFeature.AUTO_DETECT_IS_GETTERS)
.disable(MapperFeature.AUTO_DETECT_CREATORS)
.injectableValues(InjectableValues.Std(mapOf(
FILE_CONTENTS_FACTORY_INJECTABLE_VALUE to fileContentsFactory
)))
.setDateFormat()
.build()
}
fun ObjectMapper.configureCourseMapper(isEncrypted: Boolean) {
if (isEncrypted) {
registerModule(EncryptionModule())
}
val module = SimpleModule()
module.addDeserializer(StudyItem::class.java, StudyItemDeserializer())
module.addDeserializer(Course::class.java, CourseDeserializer())
registerModule(module)
addStudyItemMixins()
}
fun ObjectMapper.addStudyItemMixins() {
addMixIn(EduCourse::class.java, RemoteEduCourseMixin::class.java)
addMixIn(PluginInfo::class.java, PluginInfoMixin::class.java)
addMixIn(Section::class.java, RemoteSectionMixin::class.java)
addMixIn(FrameworkLesson::class.java, RemoteFrameworkLessonMixin::class.java)
addMixIn(Lesson::class.java, RemoteLessonMixin::class.java)
addMixIn(Task::class.java, RemoteTaskMixin::class.java)
addMixIn(ChoiceTask::class.java, ChoiceTaskLocalMixin::class.java)
addMixIn(ChoiceOption::class.java, ChoiceOptionLocalMixin::class.java)
addMixIn(TaskFile::class.java, TaskFileMixin::class.java)
addMixIn(EduFile::class.java, EduFileMixin::class.java)
addMixIn(AnswerPlaceholder::class.java, AnswerPlaceholderWithAnswerMixin::class.java)
addMixIn(AnswerPlaceholderDependency::class.java, AnswerPlaceholderDependencyMixin::class.java)
}
fun JsonMapper.Builder.setDateFormat(): JsonMapper.Builder {
val mapperDateFormat = SimpleDateFormat("MMM dd, yyyy hh:mm:ss a", Locale.ENGLISH)
mapperDateFormat.timeZone = TimeZone.getTimeZone("UTC")
return defaultDateFormat(mapperDateFormat)
}
val EduFile.pathInArchive: String
get() = "$COURSE_CONTENTS_FOLDER/$pathInCourse"