diff --git a/src/JobsJobsJobs.sln b/src/JobsJobsJobs.sln index 0de6ec4..91046a9 100644 --- a/src/JobsJobsJobs.sln +++ b/src/JobsJobsJobs.sln @@ -20,6 +20,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution database\tables.sql = database\tables.sql EndProjectSection EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "JobsJobsJobs", "JobsJobsJobs", "{FA833B24-B8F6-4CE6-A044-99257EAC02FF}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Domain", "JobsJobsJobs\Domain\Domain.fsproj", "{C81278DA-DA97-4E55-AB39-4B88565B615D}" +EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Api", "JobsJobsJobs\Api\Api.fsproj", "{8F5A3D1E-562B-4F27-9787-6CB14B35E69E}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -38,6 +44,14 @@ Global {AE329284-47DA-4E76-B542-47489B271130}.Debug|Any CPU.Build.0 = Debug|Any CPU {AE329284-47DA-4E76-B542-47489B271130}.Release|Any CPU.ActiveCfg = Release|Any CPU {AE329284-47DA-4E76-B542-47489B271130}.Release|Any CPU.Build.0 = Release|Any CPU + {C81278DA-DA97-4E55-AB39-4B88565B615D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C81278DA-DA97-4E55-AB39-4B88565B615D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C81278DA-DA97-4E55-AB39-4B88565B615D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C81278DA-DA97-4E55-AB39-4B88565B615D}.Release|Any CPU.Build.0 = Release|Any CPU + {8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F5A3D1E-562B-4F27-9787-6CB14B35E69E}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -45,4 +59,8 @@ Global GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5E9ECDBF-634E-43A9-8F89-625A2213831C} EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C81278DA-DA97-4E55-AB39-4B88565B615D} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF} + {8F5A3D1E-562B-4F27-9787-6CB14B35E69E} = {FA833B24-B8F6-4CE6-A044-99257EAC02FF} + EndGlobalSection EndGlobal diff --git a/src/JobsJobsJobs/Api/Api.fsproj b/src/JobsJobsJobs/Api/Api.fsproj new file mode 100644 index 0000000..8cb0cc1 --- /dev/null +++ b/src/JobsJobsJobs/Api/Api.fsproj @@ -0,0 +1,25 @@ + + + + Exe + net5.0 + 3390;$(WarnOn) + + + + + + + + + + + + + + + + + + + diff --git a/src/JobsJobsJobs/Api/App.fs b/src/JobsJobsJobs/Api/App.fs new file mode 100644 index 0000000..887b689 --- /dev/null +++ b/src/JobsJobsJobs/Api/App.fs @@ -0,0 +1,51 @@ +/// The main API application for Jobs, Jobs, Jobs +module JobsJobsJobs.Api.App + +//open System +open Microsoft.AspNetCore.Builder +open Microsoft.AspNetCore.Hosting +open Microsoft.Extensions.DependencyInjection +open Microsoft.Extensions.Hosting +open Giraffe + +/// All available routes for the application +let webApp = + choose [ + route "/ping" >=> text "pong" + route "/" >=> htmlFile "/pages/index.html" + ] + +/// Configure the ASP.NET Core pipeline to use Giraffe +let configureApp (app : IApplicationBuilder) = + app.UseGiraffe webApp + +open NodaTime +open RethinkDb.Driver.Net +open Microsoft.Extensions.Configuration +open Microsoft.Extensions.Logging + +/// Configure dependency injection +let configureServices (svc : IServiceCollection) = + svc.AddGiraffe() + .AddSingleton(SystemClock.Instance) + .AddLogging () + |> ignore + let svcs = svc.BuildServiceProvider() + let cfg = svcs.GetRequiredService().GetSection "Rethink" + let log = svcs.GetRequiredService().CreateLogger "Data.Startup" + let conn = Data.Startup.createConnection cfg log + svc.AddSingleton conn |> ignore + Data.Startup.establishEnvironment cfg log conn |> Data.awaitIgnore + +[] +let main _ = + Host.CreateDefaultBuilder() + .ConfigureWebHostDefaults( + fun webHostBuilder -> + webHostBuilder + .Configure(configureApp) + .ConfigureServices(configureServices) + |> ignore) + .Build() + .Run () + 0 diff --git a/src/JobsJobsJobs/Api/Data.fs b/src/JobsJobsJobs/Api/Data.fs new file mode 100644 index 0000000..7ede6f9 --- /dev/null +++ b/src/JobsJobsJobs/Api/Data.fs @@ -0,0 +1,169 @@ +/// Data access functions for Jobs, Jobs, Jobs +module JobsJobsJobs.Api.Data + +open JobsJobsJobs.Domain +open JobsJobsJobs.Domain.Types +open Polly +open RethinkDb.Driver +open RethinkDb.Driver.Net +open Microsoft.Extensions.Configuration +open FSharp.Control.Tasks +open Microsoft.Extensions.Logging + +/// Shorthand for the RethinkDB R variable (how every command starts) +let private r = RethinkDB.R + +/// Shorthand for await task / run sync / ignore (used in non-async contexts) +let awaitIgnore x = x |> Async.AwaitTask |> Async.RunSynchronously |> ignore + + +/// JSON converters used with RethinkDB persistence +module Converters = + + open Microsoft.FSharpLu.Json + open Newtonsoft.Json + open System + + /// JSON converter for citizen IDs + type CitizenIdJsonConverter() = + inherit JsonConverter() + override __.WriteJson(writer : JsonWriter, value : CitizenId, _ : JsonSerializer) = + writer.WriteValue (CitizenId.toString value) + override __.ReadJson(reader: JsonReader, _ : Type, _ : CitizenId, _ : bool, _ : JsonSerializer) = + (string >> CitizenId.ofString) reader.Value + + /// JSON converter for continent IDs + type ContinentIdJsonConverter() = + inherit JsonConverter() + override __.WriteJson(writer : JsonWriter, value : ContinentId, _ : JsonSerializer) = + writer.WriteValue (ContinentId.toString value) + override __.ReadJson(reader: JsonReader, _ : Type, _ : ContinentId, _ : bool, _ : JsonSerializer) = + (string >> ContinentId.ofString) reader.Value + + /// JSON converter for Markdown strings + type MarkdownStringJsonConverter() = + inherit JsonConverter() + override __.WriteJson(writer : JsonWriter, value : MarkdownString, _ : JsonSerializer) = + let (Text text) = value + writer.WriteValue text + override __.ReadJson(reader: JsonReader, _ : Type, _ : MarkdownString, _ : bool, _ : JsonSerializer) = + (string >> Text) reader.Value + + /// JSON converter for listing IDs + type ListingIdJsonConverter() = + inherit JsonConverter() + override __.WriteJson(writer : JsonWriter, value : ListingId, _ : JsonSerializer) = + writer.WriteValue (ListingId.toString value) + override __.ReadJson(reader: JsonReader, _ : Type, _ : ListingId, _ : bool, _ : JsonSerializer) = + (string >> ListingId.ofString) reader.Value + + /// JSON converter for skill IDs + type SkillIdJsonConverter() = + inherit JsonConverter() + override __.WriteJson(writer : JsonWriter, value : SkillId, _ : JsonSerializer) = + writer.WriteValue (SkillId.toString value) + override __.ReadJson(reader: JsonReader, _ : Type, _ : SkillId, _ : bool, _ : JsonSerializer) = + (string >> SkillId.ofString) reader.Value + + /// JSON converter for success report IDs + type SuccessIdJsonConverter() = + inherit JsonConverter() + override __.WriteJson(writer : JsonWriter, value : SuccessId, _ : JsonSerializer) = + writer.WriteValue (SuccessId.toString value) + override __.ReadJson(reader: JsonReader, _ : Type, _ : SuccessId, _ : bool, _ : JsonSerializer) = + (string >> SuccessId.ofString) reader.Value + + /// All JSON converters needed for the application + let all () = [ + CitizenIdJsonConverter () :> JsonConverter + upcast ContinentIdJsonConverter () + upcast MarkdownStringJsonConverter () + upcast ListingIdJsonConverter () + upcast SkillIdJsonConverter () + upcast SuccessIdJsonConverter () + upcast CompactUnionJsonConverter () + ] + + +/// Table names +[] +module Table = + /// The user (citizen of Gitmo Nation) table + let Citizen = "citizen" + /// The continent table + let Continent = "continent" + /// The job listing table + let Listing = "listing" + /// The citizen employment profile table + let Profile = "profile" + /// The success story table + let Success = "success" + /// All tables + let all () = [ Citizen; Continent; Listing; Profile; Success ] + + +/// Functions run at startup +[] +module Startup = + + /// Create a RethinkDB connection + let createConnection (cfg : IConfigurationSection) (log : ILogger)= + + // Add all required JSON converters + Converters.all () + |> List.iter Converter.Serializer.Converters.Add + // Read the configuration and create a connection + let bldr = + seq Connection.Builder> { + yield fun b -> match cfg.["Hostname"] with null -> b | host -> b.Hostname host + yield fun b -> match cfg.["Port"] with null -> b | port -> (int >> b.Port) port + yield fun b -> match cfg.["AuthKey"] with null -> b | key -> b.AuthKey key + yield fun b -> match cfg.["Db"] with null -> b | db -> b.Db db + yield fun b -> match cfg.["Timeout"] with null -> b | time -> (int >> b.Timeout) time + } + |> Seq.fold (fun b step -> step b) (r.Connection ()) + match log.IsEnabled LogLevel.Debug with + | true -> log.LogDebug $"RethinkDB: Connecting to {bldr.Hostname}:{bldr.Port}, database {bldr.Db}" + | false -> () + bldr.Connect () :> IConnection + + /// Ensure the data, tables, and indexes that are required exist + let establishEnvironment (cfg : IConfigurationSection) (log : ILogger) conn = task { + // Ensure the database exists + match cfg.["Db"] |> Option.ofObj with + | Some database -> + let! dbs = r.DbList().RunResultAsync conn + match dbs |> List.contains database with + | true -> () + | false -> + log.LogInformation $"Creating database {database}..." + let! _ = r.DbCreate(database).RunWriteAsync conn + () + | None -> () + // Ensure the tables exist + let! tables = r.TableList().RunResultAsync conn + Table.all () + |> List.iter ( + fun tbl -> + match tables |> List.contains tbl with + | true -> () + | false -> + log.LogInformation $"Creating {tbl} table..." + r.TableCreate(tbl).RunWriteAsync conn |> awaitIgnore) + // Ensure the indexes exist + let ensureIndexes table indexes = task { + let! tblIdxs = r.Table(table).IndexList().RunResultAsync conn + indexes + |> List.iter ( + fun idx -> + match tblIdxs |> List.contains idx with + | true -> () + | false -> + log.LogInformation $"Creating \"{idx}\" index on {table}" + r.Table(table).IndexCreate(idx).RunWriteAsync conn |> awaitIgnore) + } + do! ensureIndexes Table.Citizen [ "naUser" ] + do! ensureIndexes Table.Listing [ "citizenId"; "continentId" ] + do! ensureIndexes Table.Profile [ "continentId" ] + do! ensureIndexes Table.Success [ "citizenId" ] + } diff --git a/src/JobsJobsJobs/Api/appsettings.json b/src/JobsJobsJobs/Api/appsettings.json new file mode 100644 index 0000000..cbdb783 --- /dev/null +++ b/src/JobsJobsJobs/Api/appsettings.json @@ -0,0 +1,6 @@ +{ + "Rethink": { + "Hostname": "localhost", + "Db": "jobsjobsjobs" + } +} \ No newline at end of file diff --git a/src/JobsJobsJobs/Domain/Domain.fsproj b/src/JobsJobsJobs/Domain/Domain.fsproj new file mode 100644 index 0000000..ad1f7fe --- /dev/null +++ b/src/JobsJobsJobs/Domain/Domain.fsproj @@ -0,0 +1,19 @@ + + + + net5.0 + true + 3390;$(WarnOn) + + + + + + + + + + + + + diff --git a/src/JobsJobsJobs/Domain/Modules.fs b/src/JobsJobsJobs/Domain/Modules.fs new file mode 100644 index 0000000..9223b70 --- /dev/null +++ b/src/JobsJobsJobs/Domain/Modules.fs @@ -0,0 +1,86 @@ +/// Modules to provide support functions for types +[] +module JobsJobsJobs.Domain.Modules + +open Markdig +open System +open Types + +/// Format a GUID as a Short GUID +let private toShortGuid guid = + let convert (g : Guid) = + Convert.ToBase64String (g.ToByteArray ()) + |> String.map (fun x -> match x with '/' -> '_' | '+' -> '-' | _ -> x) + (convert guid).Substring (0, 22) + +/// Turn a Short GUID back into a GUID +let private fromShortGuid x = + let unBase64 = x |> String.map (fun x -> match x with '_' -> '/' | '-' -> '+' | _ -> x) + (Convert.FromBase64String >> Guid) $"{unBase64}==" + + +/// Support functions for citizen IDs +module CitizenId = + /// Create a new citizen ID + let create () = (Guid.NewGuid >> CitizenId) () + /// A string representation of a citizen ID + let toString = function (CitizenId it) -> toShortGuid it + /// Parse a string into a citizen ID + let ofString = fromShortGuid >> CitizenId + + +/// Support functions for citizens +module Citizen = + /// Get the name of the citizen (the first of real name, display name, or handle that is filled in) + let name x = + [ x.realName; x.displayName; Some x.naUser ] + |> List.find Option.isSome + |> Option.get + + +/// Support functions for continent IDs +module ContinentId = + /// Create a new continent ID + let create () = (Guid.NewGuid >> ContinentId) () + /// A string representation of a continent ID + let toString = function (ContinentId it) -> toShortGuid it + /// Parse a string into a continent ID + let ofString = fromShortGuid >> ContinentId + + +/// Support functions for Markdown strings +module MarkdownString = + /// The Markdown conversion pipeline (enables all advanced features) + let private pipeline = MarkdownPipelineBuilder().UseAdvancedExtensions().Build () + /// Convert this Markdown string to HTML + let toHtml = function (Text text) -> Markdown.ToHtml (text, pipeline) + + +/// Support functions for listing IDs +module ListingId = + /// Create a new job listing ID + let create () = (Guid.NewGuid >> ListingId) () + /// A string representation of a listing ID + let toString = function (ListingId it) -> toShortGuid it + /// Parse a string into a listing ID + let ofString = fromShortGuid >> ListingId + + +/// Support functions for skill IDs +module SkillId = + /// Create a new skill ID + let create () = (Guid.NewGuid >> SkillId) () + /// A string representation of a skill ID + let toString = function (SkillId it) -> toShortGuid it + /// Parse a string into a skill ID + let ofString = fromShortGuid >> SkillId + + +/// Support functions for success report IDs +module SuccessId = + /// Create a new success report ID + let create () = (Guid.NewGuid >> SuccessId) () + /// A string representation of a success report ID + let toString = function (SuccessId it) -> toShortGuid it + /// Parse a string into a success report ID + let ofString = fromShortGuid >> SuccessId diff --git a/src/JobsJobsJobs/Domain/Types.fs b/src/JobsJobsJobs/Domain/Types.fs new file mode 100644 index 0000000..fb746d0 --- /dev/null +++ b/src/JobsJobsJobs/Domain/Types.fs @@ -0,0 +1,136 @@ +/// Types within Jobs, Jobs, Jobs +module JobsJobsJobs.Domain.Types + +open NodaTime +open System + +// fsharplint:disable FieldNames + +/// The ID of a user (a citizen of Gitmo Nation) +type CitizenId = CitizenId of Guid + +/// A user of Jobs, Jobs, Jobs +type Citizen = { + /// The ID of the user + id : CitizenId + /// The handle by which the user is known on Mastodon + naUser : string + /// The user's display name from Mastodon (updated every login) + displayName : string option + /// The user's real name + realName : string option + /// The URL for the user's Mastodon profile + profileUrl : string + /// When the user joined Jobs, Jobs, Jobs + joinedOn : Instant + /// When the user last logged in + lastSeenOn : Instant + } + + +/// The ID of a continent +type ContinentId = ContinentId of Guid + +/// A continent +type Continent = { + /// The ID of the continent + id : ContinentId + /// The name of the continent + name : string + } + + +/// A string of Markdown text +type MarkdownString = Text of string + + +/// The ID of a job listing +type ListingId = ListingId of Guid + +/// A job listing +type Listing = { + /// The ID of the job listing + id : ListingId + /// The ID of the citizen who posted the job listing + citizenId : CitizenId + /// When this job listing was created + createdOn : Instant + /// The short title of the job listing + title : string + /// The ID of the continent on which the job is located + continentId : ContinentId + /// The region in which the job is located + region : string + /// Whether this listing is for remote work + remoteWork : bool + /// Whether this listing has expired + isExpired : bool + /// When this listing was last updated + updatedOn : Instant + /// The details of this job + text : MarkdownString + /// When this job needs to be filled + neededBy : LocalDate option + /// Was this job filled as part of its appearance on Jobs, Jobs, Jobs? + wasFilledHere : bool option + } + + +/// The ID of a skill +type SkillId = SkillId of Guid + +/// A skill the job seeker possesses +type Skill = { + /// The ID of the skill + id : SkillId + /// A description of the skill + description : string + /// Notes regarding this skill (level, duration, etc.) + notes : string option + } + + +/// A job seeker profile +type Profile = { + /// The ID of the citizen to whom this profile belongs + id : CitizenId + /// Whether this citizen is actively seeking employment + seekingEmployment : bool + /// Whether this citizen allows their profile to be a part of the publicly-viewable, anonymous data + isPublic : bool + /// The ID of the continent on which the citizen resides + continentId : ContinentId + /// The region in which the citizen resides + region : string + /// Whether the citizen is looking for remote work + remoteWork : bool + /// Whether the citizen is looking for full-time work + fullTime : bool + /// The citizen's professional biography + biography : MarkdownString + /// When the citizen last updated their profile + lastUpdatedOn : Instant + /// The citizen's experience (topical / chronological) + experience : MarkdownString option + /// Skills this citizen possesses + skills : Skill list + } + +/// The ID of a success report +type SuccessId = SuccessId of Guid + +/// A record of success finding employment +type Success = { + /// The ID of the success report + id : SuccessId + /// The ID of the citizen who wrote this success report + citizenId : CitizenId + /// When this success report was recorded + recordedOn : Instant + /// Whether the success was due, at least in part, to Jobs, Jobs, Jobs + fromHere : bool + /// The source of this success (listing or profile) + source : string + /// The success story + story : MarkdownString option + }