Skip to content

Commit a07b0d4

Browse files
authored
Layer Routes With Layer, Page, and Line Class Refactor (#469)
* First push at refactoring for the Layer * First push at refactoring for the Layer * Changes during readthrough and testing * Changes during readthrough and testing * Changes during readthrough and testing * Changes during readthrough and testing * Changes during readthrough and testing * Changes during readthrough and testing * Changes during readthrough and testing * Changes during readthrough and testing * Changes during readthrough and testing * Changes during readthrough and testing * Changes during review and testing * move this back * changes during testing and review * changes during testing and review * changes during testing and review * changes during testing and review * changes during testing and review * Changes from cubap review * Changes from cubap review * Make sure Layer references in the Pages are upgraded when Layer is upgraded
1 parent 2c7813f commit a07b0d4

5 files changed

Lines changed: 224 additions & 156 deletions

File tree

classes/Layer/Layer.js

Lines changed: 110 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import dbDriver from "../../database/driver.js"
2-
import { handleVersionConflict } from "../../utilities/shared.js"
32
import Page from "../Page/Page.js"
43
import { fetchUserAgent } from "../../utilities/shared.js"
54
import ProjectFactory from "../Project/ProjectFactory.js"
@@ -8,6 +7,7 @@ const databaseTiny = new dbDriver("tiny")
87

98
export default class Layer {
109
#tinyAction = 'create'
10+
#hydrated = false
1111

1212
/**
1313
* Constructs a Layer from the JSON Object in the Project `layers` Array.
@@ -17,6 +17,7 @@ export default class Layer {
1717
* @param {string} id The ID of the layer. This is the Layer stored in the Project.
1818
* @param {string} label The label of the layer. This is the Layer stored in the Project.
1919
* @param {Array} pages The pages in the layer by reference.
20+
* @param {string|null} [creator=null] The creator/agent URI for this layer.
2021
* @seeAlso {@link Layer.build}
2122
*/
2223
constructor(projectId, { id, label, pages, creator = null }) {
@@ -31,6 +32,9 @@ export default class Layer {
3132
this.label = label
3233
this.creator = creator
3334
this.pages = pages
35+
this.total = pages.length
36+
this.first = pages.at(0)?.id
37+
this.last = pages.at(-1)?.id
3438
if (this.id.startsWith(process.env.RERUMIDPREFIX)) {
3539
this.#tinyAction = 'update'
3640
}
@@ -79,13 +83,49 @@ export default class Layer {
7983
this.#setRerumId()
8084
await this.#saveCollectionToRerum()
8185
}
86+
this.total = this.pages.length
87+
this.first = this.pages.at(0)?.id
88+
this.last = this.pages.at(-1)?.id
89+
this.#hydrated = true
8290
return this.#formatCollectionForProject()
8391
}
8492

8593
asProjectLayer() {
8694
return this.#formatCollectionForProject()
8795
}
8896

97+
/**
98+
* Returns a JSON representation of the Layer as a W3C AnnotationCollection.
99+
* @param {boolean} isLD - If true, returns JSON-LD format with @context and type. If false, returns a simple object.
100+
* @returns {Promise<Object>} The Layer as JSON.
101+
*/
102+
async asJSON(isLD) {
103+
if (!this.#hydrated && this.id?.startsWith?.(process.env.RERUMIDPREFIX)) {
104+
await this.#loadAnnotationCollectionDataFromRerum()
105+
}
106+
let result
107+
if (isLD) {
108+
result = {
109+
'@context': 'http://iiif.io/api/presentation/3/context.json',
110+
id: this.id,
111+
type: 'AnnotationCollection',
112+
label: { "none": [this.label] },
113+
total: this.pages.length,
114+
first: this.pages.at(0)?.id,
115+
last: this.pages.at(-1)?.id
116+
}
117+
if (this.creator) result.creator = this.creator
118+
}
119+
else {
120+
result = {
121+
id: this.id,
122+
label: this.label,
123+
pages: this.pages
124+
}
125+
}
126+
return result
127+
}
128+
89129
// Private Methods
90130
#setRerumId() {
91131
if (!this.id.startsWith(process.env.RERUMIDPREFIX)) {
@@ -94,6 +134,45 @@ export default class Layer {
94134
return this
95135
}
96136

137+
/**
138+
* Resolve the RERUM URI of the Layer and sync Layer properties with the AnnotationCollection properties.
139+
* The RERUM data will take preference and overwrite any properties that are already set.
140+
* Only RERUM URIs are supported.
141+
*/
142+
async #loadAnnotationCollectionDataFromRerum() {
143+
if (this.id.startsWith?.(process.env.RERUMIDPREFIX)) {
144+
const rawLayerData = await fetch(this.id).then(async (resp) => {
145+
if (resp.ok) return resp.json()
146+
let rerumErrorMessage
147+
try {
148+
rerumErrorMessage = `${resp.status ?? 500}: ${this.id} - ${await resp.text()}`
149+
} catch (e) {
150+
rerumErrorMessage = `500: ${this.id} - A RERUM error occurred`
151+
}
152+
const err = new Error(rerumErrorMessage)
153+
err.status = 502
154+
throw err
155+
})
156+
.catch(err => {
157+
if (err.status === 502) throw err
158+
const genericRerumNetworkError = new Error(`500: ${this.id} - A RERUM error occurred`)
159+
genericRerumNetworkError.status = 502
160+
throw genericRerumNetworkError
161+
})
162+
if (!(rawLayerData.id || rawLayerData["@id"])) {
163+
const genericRerumNetworkError = new Error(`500: ${this.id} - A RERUM error occurred`)
164+
genericRerumNetworkError.status = 502
165+
throw genericRerumNetworkError
166+
}
167+
this.#tinyAction = 'update'
168+
this.id = rawLayerData.id ?? rawLayerData["@id"] ?? this.id
169+
if (rawLayerData.label) this.label = ProjectFactory.getLabelAsString(rawLayerData.label)
170+
if (rawLayerData.creator) this.creator = rawLayerData.creator
171+
this.#hydrated = true
172+
}
173+
return this
174+
}
175+
97176
#formatCollectionForProject() {
98177
return {
99178
id: this.id,
@@ -108,40 +187,53 @@ export default class Layer {
108187

109188
async #saveCollectionToRerum() {
110189
const layerAsCollection = {
111-
"@context": "http://www.w3.org/ns/anno.jsonld",
190+
"@context": "http://iiif.io/api/presentation/3/context.json",
112191
id: this.id,
113192
type: "AnnotationCollection",
114193
label: { "none": [this.label] },
115194
creator: await fetchUserAgent(this.creator),
116195
total: this.pages.length,
117-
first: this.pages.at(0).id,
118-
last: this.pages.at(-1).id
196+
first: this.pages.at(0)?.id,
197+
last: this.pages.at(-1)?.id
119198
}
120199

121200
if (this.#tinyAction === 'create') {
122-
await databaseTiny.save(layerAsCollection).catch(err => {
201+
await databaseTiny.save(layerAsCollection)
202+
.catch(err => {
123203
console.error(err, layerAsCollection)
124204
throw new Error(`Failed to save Layer to RERUM: ${err.message}`)
125205
})
126206
this.#tinyAction = 'update'
207+
this.#hydrated = true
127208
return this
128209
}
129210

130-
const existingLayer = await fetch(this.id).then(res => res.json())
131-
if (!existingLayer) {
132-
throw new Error(`Layer not found in RERUM: ${this.id}`)
133-
}
134-
const updatedLayer = { ...existingLayer, ...layerAsCollection }
135-
136-
// Handle optimistic locking version if available
137-
try {
138-
await databaseTiny.overwrite(updatedLayer)
139-
return this
140-
} catch (err) {
141-
if (err.status === 409) {
142-
throw handleVersionConflict(null, err)
211+
const existingLayer = await fetch(this.id).then(async (resp) => {
212+
if (resp.ok) return resp.json()
213+
let rerumErrorMessage
214+
try {
215+
rerumErrorMessage = `${resp.status ?? 500}: ${this.id} - ${await resp.text()}`
216+
} catch (e) {
217+
rerumErrorMessage = `500: ${this.id} - A RERUM error occurred`
143218
}
219+
const err = new Error(rerumErrorMessage)
220+
err.status = 502
144221
throw err
222+
})
223+
.catch(err => {
224+
if (err.status === 502) throw err
225+
const genericRerumNetworkError = new Error(`500: ${this.id} - A RERUM error occurred`)
226+
genericRerumNetworkError.status = 502
227+
throw genericRerumNetworkError
228+
})
229+
if (!(existingLayer?.id || existingLayer?.["@id"])) {
230+
const genericRerumNetworkError = new Error(`500: ${this.id} - A RERUM error occurred`)
231+
genericRerumNetworkError.status = 502
232+
throw genericRerumNetworkError
145233
}
234+
const updatedLayer = { ...existingLayer, ...layerAsCollection }
235+
await databaseTiny.overwrite(updatedLayer)
236+
this.#hydrated = true
237+
return this
146238
}
147239
}

layer/index.js

Lines changed: 22 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import cors from 'cors'
77
import common_cors from '../utilities/common_cors.json' with {type: 'json'}
88
import Project from '../classes/Project/Project.js'
99
import Layer from '../classes/Layer/Layer.js'
10-
import { findPageById, findLayerById, updateLayerAndProject, respondWithError } from '../utilities/shared.js'
10+
import { findPageById, findLayerById, updateLayerAndProject, respondWithError, handleVersionConflict } from '../utilities/shared.js'
1111
import { ACTIONS, ENTITIES, SCOPES } from '../project/groups/permissions_parameters.js'
1212

1313
const router = express.Router({ mergeParams: true })
@@ -19,35 +19,16 @@ router.route('/:layerId')
1919
.get(async (req, res) => {
2020
const { projectId, layerId } = req.params
2121
try {
22-
const layer = await findLayerById(layerId, projectId, true)
23-
if (!layer) {
24-
return respondWithError(res, 404, 'No layer found with that ID.')
25-
}
26-
if (layer.id?.startsWith(process.env.RERUMIDPREFIX)) {
27-
// If the page is a RERUM document, we need to fetch it from the server
28-
res.status(200).json(layer)
29-
return
30-
}
31-
// Make this internal Layer look more like a RERUM AnnotationCollection
32-
const layerAsCollection = {
33-
'@context': 'http://www.w3.org/ns/anno.jsonld',
34-
id: layer.id,
35-
type: 'AnnotationCollection',
36-
label: { none: [layer.label] },
37-
total: layer.pages.length,
38-
first: layer.pages.at(0).id,
39-
last: layer.pages.at(-1).id
40-
}
41-
if (layer.creator) layerAsCollection.creator = layer.creator
42-
return res.status(200).json(layerAsCollection)
22+
const layer = await findLayerById(layerId, projectId)
23+
const layerJson = await layer.asJSON(true)
24+
return res.status(200).json(layerJson)
4325
} catch (error) {
4426
console.error(error)
4527
return respondWithError(res, error.status ?? 500, error.message ?? 'Internal Server Error')
4628
}
4729
})
4830
.put(auth0Middleware(), screenContentMiddleware(), async (req, res) => {
4931
const { projectId, layerId } = req.params
50-
let label = req.body?.label
5132
const update = req.body
5233
const providedPages = update?.pages
5334
const user = req.user
@@ -56,21 +37,19 @@ router.route('/:layerId')
5637
if (!layerId) return respondWithError(res, 400, 'Layer ID is required')
5738
try {
5839
if (hasSuspiciousLayerData(req.body)) return respondWithError(res, 400, "Suspicious layer data will not be processed.")
59-
const projectObj = new Project(projectId)
60-
if (!(await projectObj.checkUserAccess(user._id, ACTIONS.UPDATE, SCOPES.ALL, ENTITIES.LAYER))) {
40+
const project = new Project(projectId)
41+
if (!(await project.checkUserAccess(user._id, ACTIONS.UPDATE, SCOPES.ALL, ENTITIES.LAYER))) {
6142
return respondWithError(res, 403, 'You do not have permission to update this layer')
6243
}
63-
const project = await Project.getById(projectId)
64-
if (!project?._id) return respondWithError(res, 404, `Project '${projectId}' does not exist`)
44+
if (!project?.data) return respondWithError(res, 404, `Project ${projectId} was not found`)
6545
const layer = await findLayerById(layerId, projectId)
66-
if (!layer?.id) return respondWithError(res, 404, `Layer '${layerId}' not found in project`)
6746
// Only update top-level properties that are present in the request
6847
Object.keys(update ?? {}).forEach(key => {
6948
layer[key] = update[key]
7049
})
7150
Object.keys(layer).forEach(key => {
7251
if (layer[key] === undefined || layer[key] === null) {
73-
// Remove properties that are undefined or null. prev and next can be null
52+
// Remove properties that are undefined or null. first and last can be null
7453
if (key !== "first" && key !== "last") delete layer[key]
7554
else layer[key] = null
7655
}
@@ -81,14 +60,22 @@ router.route('/:layerId')
8160
layer.pages = pages
8261
}
8362
await updateLayerAndProject(layer, project, user._id)
84-
res.status(200).json(layer)
63+
const layerJson = await layer.asJSON(true)
64+
res.status(200).json(layerJson)
8565
} catch (error) {
8666
console.error(error)
87-
return respondWithError(res, error.status ?? 500, error.message ?? 'Error updating layer')
67+
// Handle version conflicts with optimistic locking
68+
if (error.status === 409) {
69+
if (res.headersSent) return
70+
return handleVersionConflict(res, error)
71+
} else {
72+
if (res.headersSent) return
73+
return respondWithError(res, error.status ?? 500, error.message ?? 'Error updating layer')
74+
}
8875
}
8976
})
9077
.all((req, res) => {
91-
return respondWithError(res, 405, 'Improper request method. Use GET instead.')
78+
return respondWithError(res, 405, 'Improper request method. Use GET or PUT.')
9279
})
9380

9481
// Route to create a new layer within a project
@@ -106,12 +93,11 @@ router.route('/').post(auth0Middleware(), screenContentMiddleware(), async (req,
10693
}
10794
try {
10895
if (hasSuspiciousLayerData(req.body)) return respondWithError(res, 400, "Suspicious layer data will not be processed.")
109-
const projectObj = new Project(projectId)
110-
if (!(await projectObj.checkUserAccess(user._id, ACTIONS.CREATE, SCOPES.ALL, ENTITIES.LAYER))) {
96+
const project = new Project(projectId)
97+
if (!(await project.checkUserAccess(user._id, ACTIONS.CREATE, SCOPES.ALL, ENTITIES.LAYER))) {
11198
return respondWithError(res, 403, 'You do not have permission to create layers in this project')
11299
}
113-
const project = await Project.getById(projectId)
114-
if (!project) return respondWithError(res, 404, 'Project does not exist')
100+
if (!project?.data) return respondWithError(res, 404, `Project ${projectId} was not found`)
115101
const newLayer = Layer.build(projectId, label, canvases)
116102
project.addLayer(newLayer.asProjectLayer())
117103
await project.update()

0 commit comments

Comments
 (0)