Switching to the New Module Library

Project creation - gives APIError: 422 Unprocessable Entity: Unprocessable entity.
For what input?

Project name field on Module Library - Vassal

Any input - say

  • AfrikaKorps
  • Afrika Korps
  • 4000

As in they are not meant to work? :slight_smile: Seriously, what do you mean? I don’t think users can do any of those things listed above, so clearly they do not “work as intended” - unless, of course, those are admin responsibilities.

I agree that you can change the Title field (as well as sort field, etc.), but the project record has more fields that could be of interest

  {
    "name": "4",
    "description": "",
    "revision": 1,
    "created_at": "2010-05-30T15:45:42Z",
    "modified_at": "2024-03-09T00:42:01Z",
    "tags": [],
    "game": {
      "title": "11 de Setembre Setge 1714",
      "title_sort_key": "11 de setembre setge 1714",
      "publisher": "Cat Imperium",
      "year": "2008",
      "players": {
        "min": null,
        "max": null
      },
      "length": {
        "min": null,
        "max": null
      }
    }
  },

But, I think this points to the confusion - when you enter a name in https://vassalengine.org/library/new you are presumably entering a project name, but the code underneath puts it as game name. And the project name is not the same as the project id (it is for those that were auto-generated). What exactly is supposed to happen?

Maybe https://vassalengine.org/library/new should just have a big button that says “Create New Project” and that will auto-assign a project name (a serial number, say), and then one can enter the meta information later on. But this begs the question as to how to navigate the projects - both as user and maintainer. Effectively, when users browse the “project” library at https://vassalengine.org/library/projects what they are seeing is not the projects but the games, and I can only the imagine the confusion when they see 10 versions of Napoleon at Waterloo.

Yours,
Christian

1 Like

Packages aren’t removable, image galleries aren’t editable, and there’s no link for creating packages because those things haven’t been implemented yet. If you found that you could do those things right now, that would be unexpected and cause for concern.

The project name is copied as the initial game title when you create a new project. You can edit the game title subsequently.

The project id isn’t something visible outside the database. The project name is what you’re seeing. For projects converted from the old module library, the project name happens to be the same as the proejct id.

If you want an example of how multiple projects for one game could look like when browsing/searching the library, have a look at the search results for Terraforming Mars; you will see the game name repeated 4 times, but under each is a different project name and a different project description so you can easily select the correct one.

1 Like

Will they be though? Or will one be able to move releases or files between packages? Since the paradigm for the library has changed quite a bit, I can see many cases where that would be desirable.

Will they be?

It seems like @jrwatts was able to create projects though, or did some admin intervene?

I think it is fair to say that there are still many things missing form the new module library. Don’t get me wrong - I think there are many nice things about the new module library, but I don’t think it is really ready for “production” yet.

A suggestion: Perhaps one could add, above or below, the various input boxes, a short reminder of what is expected in that box. For example, when creating a new release, it could say

Release numbers are of the form x.y.z possibly with a patch version like x.y.z-a, where x,y,z are whole numbers, and a is alpha-numeric.

and for projects, it could say

Project names starts with a letter followed by up to 63 letters, digits, dashes, or underscores. Spaces, colons, periods, commas, and similar are not allowed.

to give the users a bit of help, and so admins are not constantly bugged with those issues.

What @jrwatts illustrates above I think shows the possible confusion. There’s no link between the various implementations of Terraforming Mars - other than the game name, and users could be excused for finding the choices hard to navigate. The lack of link between the projects also means that one Terraforming Mars project could say different things for - say - number of players, play time, and so on. The problem, I think, get’s compounded because the search does not only search for game names, but also looks elsewhere - e.g., a search for “Mars” or “Terraforming” shows a slew of projects.

Yours,
Christian

I was able to create the 4 projects and then some packages within each, but Joel had to manually associate the files with the packages, because:

  1. The files were already uploaded, so uploading them again would be a waste of time and (potentially) server space, and
  2. Many of the existing module files do not comply to the new x.y.z versioning system, and so could not have been uploaded anyway.

I was able to set the Game Name, Player Count, Game Time, and sort name fields myself, however. If you look at the individual projects, you will also see that I edited the Readme sections to provide more info about which version did what and also added links to at least some of the other versions from the Readmes.

It would be fairly easy to implement removing empty packages or releases. I’ll probably do that soon.

Removing nonempty packages or releases is significantly more challenging, since the files in them would need to be moved somewhere else. It’s not clear to me how an interface for that should be, or if it should be possible at all, as I’d intended for file URLs to be permalinks.

Yes, but image galleries being editable is one of the lowest priority things on my list. All the code is pubic, so so someone could propose a PR to do it if waiting for me isn’t acceptable.

I still get

APIError: 422 Unprocessable Entity: Unprocessable entity

As far as I can tell, this error comes from somewhere in the game library backend, and seems to be a case of wrong-full JSON parsing.

For example, entering afrika_korps_wga produces thee POST request (doFetch)

{
    "signal": {},
    "method": "POST",
    "headers": {
        "Authorization": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjUwNDk3LCJpYXQiOjE3NTAxNjA3NjksImV4cCI6MTc1MDE2MTA2OX0.pi9sZ2ENKa4hb9K1dBUrgwwL-8rRbNcwRwc4lJmMPlQ",
        "Content-Type": "application/json"
    },
    "body": "{\"description\":\"\",\"tags\":[],\"game\":{\"title\":\"afrika_korps_wga\",\"title_sort_key\":\"afrika_korps_wga\",\"publisher\":\"\",\"year\":\"\"},\"readme\":\"\"}"
}

which gives the response

{
  body:  (...)  
  bodyUsed:  false
  headers: Headers {}
  ok: false
  redirected: false
  status: 422
  statusText: "Unprocessable Entity"
  type: "basic"
  url: "https://vassalengine.org/api/gls/v1/projects/afrika_korps_wga"
}

The error seems to originate from pub async fn project_post where the above "body" is (or perhaps full request) is wrapped in as JSON.

While I think you’ve made an effort to clarify the situation in the individual projects - which is the best that can be done, I think - it is far from built-in to the schema of the library. I would have preferred that the system did this for us, rather than relying on individuals to take the time and effort to do it.

Yours,
Christian

Just a thought: Perhaps there need to be at least one space between the : after the key and the first " of the value. Currently, you have

{"description":"",
 "tags":[],
 "game":{
     "title":"afrika_korps_wga",
     "title_sort_key":"afrika_korps_wga",
     "publisher":"",
     "year":""},
 "readme":""}

but perhaps it should be

{"description": "",
 "tags": [],
 "game": {
     "title": "afrika_korps_wga",
     "title_sort_key": "afrika_korps_wga",
     "publisher": "",
     "year": ""},
 "readme": ""}

JSON doesn’t seem to mandate it, but I believe some parsers are pretty senstive to that.

Yours,
Christian

The problem is not whitspace in the JSON. The parser, serde_json, is not sensitive to whitespace.

About permalinks - if you want that, then the files should not be stored in some project/package/release specific location - as is done, e.g.,

          "files": [
            {
              "filename": "AfrikaKorps.vmod",
              "url": "https://obj.vassalengine.org/gls/a/3/AfrikaKorps.vmod",
              "size": 6268233,
              "sha256": "a3acb6a81a88acf5ba3075dc0c0a36cb1cd3c4de589b043b6993f26eef45d97b",
              "published_at": "2025-06-13T00:11:04.076010437Z",
              "published_by": "cholmcc",
              "requires": null,
              "authors": []
            }
          ]
        }
      ]

But then moving a file to a different project/package/release should simply be to move the identifier to belong to the new project/package/release (change of one field in the DB). You could have a special project/package/release entry called Orphans where files are moved when their release is removed or similar, and then let the release interface import from there. The Orphans archive (and corresponding files) could then be periodically pruned to save server space.

BTW, what is the requires field for in the above record? I guess it is intended for extensions, saves, and logs.

Yours,
Christian

OK.

With the caveat that I’m fairly new to Rust, I will venture a suggestion: Perhaps the "players" and "length" fields must be specified in "game"?

With the code (Updated to be more like the code in the backend)

use serde::{Deserialize, Serialize};

#[derive(Debug, Default, Deserialize, Serialize)]
pub struct MaybeRangePost {
    pub min: Option<u32>,
    pub max: Option<u32>
}

#[derive(Debug, thiserror::Error)]
#[error("min > max: {0:?}")]
pub struct RangePostError(MaybeRangePost);

#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(try_from = "MaybeRangePost")]
pub struct RangePost {
    pub min: Option<u32>,
    pub max: Option<u32>
}

impl TryFrom<MaybeRangePost> for RangePost {
    type Error = RangePostError;

    fn try_from(m: MaybeRangePost) -> Result<Self, Self::Error> {
        match (m.min, m.max) {
            (None, _) | (_, None) => Ok(RangePost{ min: m.min, max: m.max }),
            (Some(min), Some(max)) if min <= max =>
                Ok(RangePost{ min: m.min, max: m.max }),
            _ => Err(RangePostError(m))
        }
    }
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct GameDataPost {
    pub title: String,
    pub title_sort_key: String,
    pub publisher: String,
    pub year: String,
    pub players: RangePost, // If Option<RangePost> then OK
    pub length: RangePost   // If Option<RangePost> then OK
}

#[derive(Debug, Default, Deserialize, Serialize)]
pub struct MaybeProjectDataPost {
    pub description: String,
    pub tags: Vec<String>,
    pub game: GameDataPost,
    pub readme: String,
    pub image: Option<String>
}

const DESCRIPTION_MAX_LENGTH: usize = 1024;
const GAME_TITLE_MAX_LENGTH: usize = 256;
const GAME_TITLE_SORT_KEY_MAX_LENGTH: usize = 256;
const GAME_PUBLISHER_MAX_LENGTH: usize = 256;
const GAME_YEAR_MAX_LENGTH: usize = 32;
const README_MAX_LENGTH: usize = 65536;
const IMAGE_MAX_LENGTH: usize = 256;

impl MaybeProjectDataPost {
    fn overlong(&self) -> bool {
        matches!(
            &self.description,
            s if s.len() > DESCRIPTION_MAX_LENGTH
        ) ||
        matches!(
            &self.readme,
            s if s.len() > README_MAX_LENGTH
        ) ||
        matches!(
            &self.image,
            Some(s) if s.len() > IMAGE_MAX_LENGTH
        ) ||
        if let game = &self.game {
            matches!(
                &game.title,
                s if s.len() > GAME_TITLE_MAX_LENGTH
            ) ||
            matches!(
                &game.title_sort_key,
                s if s.len() > GAME_TITLE_SORT_KEY_MAX_LENGTH
            ) ||
            matches!(
                &game.publisher,
                s if s.len() > GAME_PUBLISHER_MAX_LENGTH
            ) ||
            matches!(
                &game.year,
                s if s.len() > GAME_YEAR_MAX_LENGTH
            )
        }
        else {
            false
        }
    }
}

#[derive(Debug, Deserialize, Serialize)]
#[serde(try_from = "MaybeProjectDataPost")]
pub struct ProjectDataPost {
    pub description: String,
    pub tags: Vec<String>,
    pub game: GameDataPost,
    pub readme: String,
    pub image: Option<String>
}

#[derive(Debug, thiserror::Error)]
#[error("invalid data {0:?}")]
pub struct ProjectDataPostError(MaybeProjectDataPost);

impl TryFrom<MaybeProjectDataPost> for ProjectDataPost {
    type Error = ProjectDataPostError;

    fn try_from(m: MaybeProjectDataPost) -> Result<Self, Self::Error> {
        // field lengths must be within bounds
        if m.overlong() {
            Err(ProjectDataPostError(m))
        }
        else {
            Ok(
                ProjectDataPost{
                    description: m.description,
                    tags: m.tags,
                    game: m.game,
                    readme: m.readme,
                    image: m.image
                }
            )
        }
    }
}

fn untyped_example() -> serde_json::Result<ProjectDataPost> {
    // Some JSON input data as a &str. Maybe this comes from the user.
    let data_bad = r#"
         {"description":"foo",
          "tags":[],
          "game":{
              "title":"afrika_korps_wga",
              "title_sort_key":"afrika_korps_wga",
              "publisher":"",
              "year":""},
          "readme":""}"#;
    let data_good = r#"
         {"description":"foo",
          "tags":[],
          "game":{
              "title":"afrika_korps_wga",
              "title_sort_key":"afrika_korps_wga",
              "publisher":"",
              "year":"",
              "players":{"min":0,"max":0},
              "length":{"min":0,"max":0}},
          "readme":""}"#;

    let r: ProjectDataPost = serde_json::from_str(data_good)?;
    println!("game is {:#?}",r);

    let r : ProjectDataPost = serde_json::from_str(data_bad)?;
    println!("game is {:#?}",r);


    
    Ok(r)
}

fn main() {
    let r = untyped_example();
    match r {
        Ok(data) => println!("{:#?}", data),
        Err(error) => println!("{}", error)
    };                             
}

for the data data_bad (with no “players” nor “length” fields from the “game” field), then I get a parse error. For data_good, which has those fields, I get no error. This is the output (Updated with respect to the above code)

game is ProjectDataPost {
    description: "foo",
    tags: [],
    game: GameDataPost {
        title: "afrika_korps_wga",
        title_sort_key: "afrika_korps_wga",
        publisher: "",
        year: "",
        players: RangePost {
            min: Some(
                0,
            ),
            max: Some(
                0,
            ),
        },
        length: RangePost {
            min: Some(
                0,
            ),
            max: Some(
                0,
            ),
        },
    },
    readme: "",
    image: None,
}
missing field `players` at line 8 column 24

Another option would be to make players and length of type Option<RangePost>.

Anyways, just a thought.

Yours,
Christian

getting this now after logging out and back in

thx

I hacked the JS of the front end and forced the player and length fields to be set. That created the project, so I guess my hunch was correct.

It should be possible to create projects once again. The backend was expecting all fields to have values in the JSON submitted for project creation.

1 Like

OK, I’m almost there in creating a project:

Don’t see how to add sample images, but if I read rightly that is still in limbo. The big deal: adding the vmod file. I’ve got the package line, showing cube image, 1.0.0, then a +. I expected clicking on the plus to give me the upload capability, but if it does I haven’t figured it out. How do I associate a file with the package? That done, I presume I’m in.

  • Projects contain packages.

  • Packages contain releases.

  • Releases contain files.

Thought I now understood, but when I try to upload, I get:
APIError: 400 Bad Request: unexpected end of input while parsing minor version number

What is the version number in the module you’re trying to upload?

Probably the version number set in the VMOD is not of the format

  • major.minor.patch[-step]

    where

    • major, minor, and patch are whole numbers (0,1,2,…) and mandatory
    • step is optional and alpha-numeric

E.g.,

1.2.3
1.0.1
3.0.0
12.5.99-beta
0.99.1-prerelease

I.e., so-called semantic versioning.

Yours,
Christian