diff --git a/README.md b/README.md index 895c5e3..20c0119 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,21 @@ # Lovdata A PowerShell module for working with Norwegian legal data from [Lovdata](https://lovdata.no), the foundation that maintains the -authoritative, continuously updated body of Norwegian law. The module wraps the [Lovdata API](https://api.lovdata.no/swagger/index.html) -so laws (`lover`) and regulations (`forskrifter`) can be listed, inspected, and pulled into scripts as PowerShell objects instead of -scraped HTML. +authoritative, continuously updated body of Norwegian law. + +**No account needed.** This release wraps the open, key-free part of the [Lovdata API](https://api.lovdata.no/swagger/index.html): +the current acts (`lover`) and central regulations (`forskrifter`) published as free open data under +[NLOD 2.0](https://data.norge.no/nlod/no/2.0), plus the service endpoints that report whether the API is up and which build is +deployed. Install the module and pull the full corpus of Norwegian acts in two commands, with nothing to configure and no +credential to obtain. ## Prerequisites - PowerShell 7 or later on Windows, Linux, or macOS. -- An API key from Lovdata for the authenticated endpoints. Lovdata issues keys to users with the `api` role in their user base; - contact [api@lovdata.no](mailto:api@lovdata.no) to request one. The key is sent as the `X-API-Key` request header on every call. -- No key is needed for Lovdata's free public datasets, which are published under - [NLOD 2.0](https://data.norge.no/nlod/no/2.0). See [Lovdata's API information page](https://lovdata.no/info/api) for the background. +- No account, no API key, no configuration. -The API also rate limits each key. Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers, and -the module surfaces the remaining budget on verbose output so long-running scripts can pace themselves. +The API rate limits every caller. Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers, +and the module surfaces the remaining budget on verbose output so long-running scripts can pace themselves. ## Installation @@ -27,35 +28,35 @@ Import-Module -Name Lovdata ## Capabilities -Store the API key once. It is encrypted at rest by the [Context](https://psmodule.io/Context/) module and reused by every -subsequent command, so scripts never carry the key themselves. +Check that the service is up and see which build is deployed: ```powershell -Connect-LovdataAccount -ApiKey (Read-Host -Prompt 'Lovdata API key' -AsSecureString) +Test-LovdataConnection +Get-LovdataApiVersion ``` -List the legal sources the account can reach, then narrow to the ones of interest: +List the free open data packages Lovdata publishes, with the size and last-modified date of each: ```powershell -Get-LovdataLegalSource -Get-LovdataLegalSource -ID 'lov*' +Get-LovdataPublicDataset +Get-LovdataPublicDataset -FileName 'gjeldende-*' ``` -Keep several keys side by side — one per environment or customer — and switch between them without reconnecting: +Download the current acts and central regulations to a folder, with progress and without silently overwriting existing files: ```powershell -Connect-LovdataAccount -ApiKey $productionKey -Context 'production' -Get-LovdataContext -ListAvailable -Switch-LovdataContext -Context 'production' +Get-LovdataPublicDataset -FileName 'gjeldende-*' | Save-LovdataPublicDataset -Path './lovdata' ``` -Module-wide defaults, such as the API base URI, live in their own context and can be inspected or changed: +Point the module at a different API base URI for the session, for example a test deployment: ```powershell Get-LovdataConfig Set-LovdataConfig -Name ApiBaseUri -Value 'https://api.lovdata.no' ``` +See the [examples](examples) folder for complete scripts, including downloading and unpacking the full corpus. + ## Attribution Lovdata publishes current laws and central regulations as open data under the @@ -63,6 +64,12 @@ Lovdata publishes current laws and central regulations as open data under the remains subject to Lovdata's terms; credit Lovdata as the source when redistributing it. This module is not affiliated with or endorsed by Lovdata. +## The paid surface + +Everything Lovdata offers behind an API key -- search, document retrieval, structured rules, vocabularies, reference resolution -- +is not covered by this release. That authenticated surface, together with the credential store it needs, is tracked in +[PSModule/Lovdata#15](https://github.com/PSModule/Lovdata/issues/15). + ## Documentation Documentation is published at [psmodule.io/Lovdata](https://psmodule.io/Lovdata/). @@ -71,5 +78,5 @@ Use PowerShell help and command discovery for module details: ```powershell Get-Command -Module Lovdata -Get-Help -Name Get-LovdataLegalSource -Examples +Get-Help -Name Get-LovdataPublicDataset -Examples ``` diff --git a/SECURITY.md b/SECURITY.md index 56b5fb1..7b18f23 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,9 +19,11 @@ triaged as quickly as possible. ## Credentials handled by this module -This module stores a Lovdata API key using the [`Context`](https://github.com/PSModule/Context) module, which encrypts secrets -at rest. Keys are never written to the repository, module source, or command output. If a key is exposed, revoke it with -Lovdata and remove the stored context with `Disconnect-LovdataAccount`. +This release handles no credentials. Everything it covers is Lovdata's open, key-free surface, so the module stores no API key +and no other secret, on disk or in memory. There is nothing for this module to leak. + +The authenticated Lovdata surface, and the encrypted credential store it will need, are tracked separately in +[PSModule/Lovdata#15](https://github.com/PSModule/Lovdata/issues/15); this section will be revisited when that lands. Problems with the Lovdata service itself, rather than with this module, belong with Lovdata's own tech support at [api@lovdata.no](mailto:api@lovdata.no). diff --git a/examples/Get-LegalSources.ps1 b/examples/Get-LegalSources.ps1 deleted file mode 100644 index f7e3e80..0000000 --- a/examples/Get-LegalSources.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -<# - .SYNOPSIS - Connect to the Lovdata API and list the available legal sources. - - .DESCRIPTION - Stores a Lovdata API key in an encrypted context, lists the legal sources the account can reach, - and narrows the result to Norwegian acts. The stored context is reused by every later command in - the session, so the key is entered once. -#> - -Import-Module -Name Lovdata - -# The key is read interactively so it never ends up in the script or the shell history. -Connect-LovdataAccount -ApiKey (Read-Host -Prompt 'Lovdata API key' -AsSecureString) - -# Every legal source the account has access to. -Get-LovdataLegalSource - -# Only the sources whose identifier starts with 'lov'. -Get-LovdataLegalSource -ID 'lov*' - -Disconnect-LovdataAccount diff --git a/examples/Get-OpenDataPackages.ps1 b/examples/Get-OpenDataPackages.ps1 new file mode 100644 index 0000000..cc0106a --- /dev/null +++ b/examples/Get-OpenDataPackages.ps1 @@ -0,0 +1,30 @@ +<# + .SYNOPSIS + Download the current Norwegian acts and regulations and unpack them locally. + + .DESCRIPTION + Lists Lovdata's free open data packages, downloads the current acts and central regulations into a + local folder, and unpacks the .tar.bz2 archives with the tar tool that ships with PowerShell 7. + No account and no API key are needed. The unpacked content is published under NLOD 2.0; credit + Lovdata as the source when you redistribute it. +#> + +Import-Module -Name Lovdata + +$destination = Join-Path -Path (Get-Location) -ChildPath 'lovdata' +$null = New-Item -Path $destination -ItemType Directory -Force + +# See what Lovdata publishes, with the size and last-modified date of each package. +Get-LovdataPublicDataset | Format-Table -Property FileName, SizeBytes, LastModified + +# Download the current acts and central regulations, overwriting any earlier copies. +$packages = Get-LovdataPublicDataset -FileName 'gjeldende-*' | + Save-LovdataPublicDataset -Path $destination -Force + +# Unpack each downloaded archive next to itself. +foreach ($package in $packages) { + $target = Join-Path -Path $destination -ChildPath $package.BaseName + $null = New-Item -Path $target -ItemType Directory -Force + tar -xjf $package.FullName -C $target + "Unpacked [$($package.Name)] into [$target]." +} diff --git a/examples/Test-ServiceAndListData.ps1 b/examples/Test-ServiceAndListData.ps1 new file mode 100644 index 0000000..d6fd71a --- /dev/null +++ b/examples/Test-ServiceAndListData.ps1 @@ -0,0 +1,22 @@ +<# + .SYNOPSIS + Check that the Lovdata API is reachable before running a data job. + + .DESCRIPTION + Uses the open service endpoints to confirm the Lovdata API is up and to record which build is + deployed, then lists the available open data packages. All of this works with no account and no + API key, so it is a safe first step in an unattended script. +#> + +Import-Module -Name Lovdata + +if (-not (Test-LovdataConnection)) { + Write-Warning 'The Lovdata API is not reachable right now. Try again later.' + return +} + +$version = Get-LovdataApiVersion +"Connected to Lovdata API [$($version.Name)] build [$($version.Timestamp)]." + +# List the open data packages the service currently offers. +Get-LovdataPublicDataset | Format-Table -Property FileName, SizeBytes, LastModified diff --git a/examples/Use-MultipleContexts.ps1 b/examples/Use-MultipleContexts.ps1 deleted file mode 100644 index c58fcad..0000000 --- a/examples/Use-MultipleContexts.ps1 +++ /dev/null @@ -1,30 +0,0 @@ -<# - .SYNOPSIS - Work with several Lovdata API keys side by side. - - .DESCRIPTION - Stores two named Lovdata contexts, inspects them, switches the default between them, and adjusts - a module-wide setting. Useful when the same script has to run against more than one Lovdata - account, for example a test account and a production account. -#> - -Import-Module -Name Lovdata - -Connect-LovdataAccount -ApiKey (Read-Host -Prompt 'Test API key' -AsSecureString) -Context 'test' -Connect-LovdataAccount -ApiKey (Read-Host -Prompt 'Production API key' -AsSecureString) -Context 'production' - -# Inspect what is stored. The API key itself is never returned in clear text. -Get-LovdataContext -ListAvailable - -# Commands without an explicit -Context use the default one. -Switch-LovdataContext -Context 'production' -Get-LovdataLegalSource - -# A single command can target another context without changing the default. -Get-LovdataLegalSource -Context 'test' - -# Module-wide defaults live in their own context. -Get-LovdataConfig -Set-LovdataConfig -Name ApiBaseUri -Value 'https://api.lovdata.no' - -Get-LovdataContext -ListAvailable | Disconnect-LovdataAccount diff --git a/src/classes/public/LovdataApiVersion.ps1 b/src/classes/public/LovdataApiVersion.ps1 new file mode 100644 index 0000000..ed60fc4 --- /dev/null +++ b/src/classes/public/LovdataApiVersion.ps1 @@ -0,0 +1,32 @@ +# The version of the deployed Lovdata API service, as reported by the '/version' endpoint. +class LovdataApiVersion { + # The name of the deployment, for example 'lovdata-api'. + [string] $Name + + # The build timestamp of the deployment, for example '2026-07-31-1613'. + [string] $Timestamp + + # The source revision the deployment was built from. + [string] $Revision + + LovdataApiVersion() {} + + LovdataApiVersion([hashtable] $Properties) { + foreach ($name in $Properties.Keys) { + $this.$name = $Properties[$name] + } + } + + LovdataApiVersion([pscustomobject] $Object) { + $known = [LovdataApiVersion].GetProperties().Name + foreach ($property in $Object.PSObject.Properties) { + if ($known -contains $property.Name) { + $this.($property.Name) = $property.Value + } + } + } + + [string] ToString() { + return '{0} {1}' -f $this.Name, $this.Timestamp + } +} diff --git a/src/classes/public/LovdataConfig.ps1 b/src/classes/public/LovdataConfig.ps1 index fa864aa..de2d7b1 100644 --- a/src/classes/public/LovdataConfig.ps1 +++ b/src/classes/public/LovdataConfig.ps1 @@ -1,15 +1,8 @@ -# Module-wide settings for the Lovdata module, stored in a module-scoped context so they are shared -# by every user context in the vault. +# Module-wide settings for the Lovdata module, kept in memory for the current session. class LovdataConfig { - # The ID of the context that holds this configuration. - [string] $ID - - # The base URI new contexts connect to, for example 'https://api.lovdata.no'. + # The base URI the module sends requests to, for example 'https://api.lovdata.no'. [string] $ApiBaseUri - # The name of the context used by commands that are not given one explicitly. - [string] $DefaultContext - LovdataConfig() {} LovdataConfig([hashtable] $Properties) { @@ -28,6 +21,6 @@ class LovdataConfig { } [string] ToString() { - return $this.ID + return $this.ApiBaseUri } } diff --git a/src/classes/public/LovdataContext.ps1 b/src/classes/public/LovdataContext.ps1 deleted file mode 100644 index c1e7810..0000000 --- a/src/classes/public/LovdataContext.ps1 +++ /dev/null @@ -1,39 +0,0 @@ -# A stored Lovdata connection: the API key plus everything needed to reach the API with it. -# The key is kept as a SecureString so it is never held in memory or written to disk in clear text. -class LovdataContext { - # The name the context is stored and selected by. - [string] $ID - - # The base URI this context connects to, for example 'https://api.lovdata.no'. - [string] $ApiBaseUri - - # The authentication scheme used against the API. Lovdata accepts an API key in the 'X-API-Key' header. - [string] $AuthType - - # The Lovdata API key. - [securestring] $ApiKey - - # When the API key was stored, so a stale context can be recognised. - [System.Nullable[datetime]] $ConnectedAt - - LovdataContext() {} - - LovdataContext([hashtable] $Properties) { - foreach ($name in $Properties.Keys) { - $this.$name = $Properties[$name] - } - } - - LovdataContext([pscustomobject] $Object) { - $known = [LovdataContext].GetProperties().Name - foreach ($property in $Object.PSObject.Properties) { - if ($known -contains $property.Name) { - $this.($property.Name) = $property.Value - } - } - } - - [string] ToString() { - return $this.ID - } -} diff --git a/src/classes/public/LovdataLegalSource.ps1 b/src/classes/public/LovdataLegalSource.ps1 deleted file mode 100644 index 1c2ad66..0000000 --- a/src/classes/public/LovdataLegalSource.ps1 +++ /dev/null @@ -1,29 +0,0 @@ -# A legal source (base) in Lovdata's databases, such as Norwegian acts or central regulations. -class LovdataLegalSource { - # The identifier used by the API to address the source, for example 'lov'. - [string] $ID - - # The human readable name of the source, as Lovdata describes it. - [string] $Description - - LovdataLegalSource() {} - - LovdataLegalSource([hashtable] $Properties) { - foreach ($name in $Properties.Keys) { - $this.$name = $Properties[$name] - } - } - - LovdataLegalSource([pscustomobject] $Object) { - $known = [LovdataLegalSource].GetProperties().Name - foreach ($property in $Object.PSObject.Properties) { - if ($known -contains $property.Name) { - $this.($property.Name) = $property.Value - } - } - } - - [string] ToString() { - return $this.ID - } -} diff --git a/src/classes/public/LovdataPublicDataset.ps1 b/src/classes/public/LovdataPublicDataset.ps1 new file mode 100644 index 0000000..3307fdb --- /dev/null +++ b/src/classes/public/LovdataPublicDataset.ps1 @@ -0,0 +1,35 @@ +# A public data package Lovdata publishes as free open data, such as the current acts or regulations. +class LovdataPublicDataset { + # The name of the package file, for example 'gjeldende-lover.tar.bz2'. + [string] $FileName + + # The description Lovdata gives the package. + [string] $Description + + # The size of the package in bytes. + [long] $SizeBytes + + # When the package was last updated. + [datetime] $LastModified + + LovdataPublicDataset() {} + + LovdataPublicDataset([hashtable] $Properties) { + foreach ($name in $Properties.Keys) { + $this.$name = $Properties[$name] + } + } + + LovdataPublicDataset([pscustomobject] $Object) { + $known = [LovdataPublicDataset].GetProperties().Name + foreach ($property in $Object.PSObject.Properties) { + if ($known -contains $property.Name) { + $this.($property.Name) = $property.Value + } + } + } + + [string] ToString() { + return $this.FileName + } +} diff --git a/src/functions/private/API/Invoke-LovdataAPI.ps1 b/src/functions/private/API/Invoke-LovdataAPI.ps1 index 3454253..9de3089 100644 --- a/src/functions/private/API/Invoke-LovdataAPI.ps1 +++ b/src/functions/private/API/Invoke-LovdataAPI.ps1 @@ -1,24 +1,27 @@ function Invoke-LovdataAPI { <# .SYNOPSIS - Send an authenticated request to the Lovdata API. + Send a request to the Lovdata API. .DESCRIPTION - Owns every HTTP call the module makes. Builds the request URI from the context's base URI and the - endpoint, injects the API key as the 'X-API-Key' header Lovdata expects, and returns the response - body deserialized from JSON when the API sends JSON. Failures are translated into terminating - errors that carry the API's own problem description, with dedicated guidance for a rejected key - and for an exhausted rate limit. + Owns every JSON HTTP call the module makes. Builds the request URI from the module's configured + base URI and the endpoint, and returns the response body deserialized from JSON when the API + sends JSON, or the raw text otherwise. Failures are translated into terminating errors that carry + the API's own problem description, with dedicated guidance for an exhausted rate limit. + + The open Lovdata endpoints this release covers need no credential, so no authentication header is + sent. The service still rate limits unauthenticated callers, so the remaining budget it reports is + written to the verbose stream. .EXAMPLE - Invoke-LovdataAPI -Endpoint '/v1/legalSource/list' -Context $context + Invoke-LovdataAPI -Endpoint '/v1/publicData/list' - Sends an authenticated GET request and returns the deserialized response. + Sends a GET request and returns the deserialized response. .EXAMPLE - Invoke-LovdataAPI -Endpoint '/v1/search' -Query @{ q = 'arbeidsmiljo' } -Context $context + Invoke-LovdataAPI -Endpoint '/version' - Sends an authenticated GET request with a URL-encoded query string. + Sends a GET request to an endpoint that answers with plain text and returns it unchanged. .INPUTS None @@ -36,7 +39,7 @@ function Invoke-LovdataAPI { [OutputType([object])] [CmdletBinding()] param( - # The API endpoint to call, relative to the context's base URI, for example '/v1/legalSource/list'. + # The API endpoint to call, relative to the configured base URI, for example '/v1/publicData/list'. [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $Endpoint, @@ -54,15 +57,11 @@ function Invoke-LovdataAPI { # The request payload, serialized as JSON. [Parameter()] [AllowNull()] - [object] $Body, - - # The resolved context holding the API key and base URI to use. - [Parameter(Mandatory)] - [ValidateNotNull()] - [LovdataContext] $Context + [object] $Body ) - $uri = '{0}/{1}' -f $Context.ApiBaseUri.TrimEnd('/'), $Endpoint.TrimStart('/') + $baseUri = (Get-LovdataConfig).ApiBaseUri + $uri = '{0}/{1}' -f $baseUri.TrimEnd('/'), $Endpoint.TrimStart('/') $queryString = @( $Query.GetEnumerator() | @@ -79,10 +78,7 @@ function Invoke-LovdataAPI { $params = @{ Method = $Method Uri = $uri - Headers = @{ - 'X-API-Key' = ConvertFrom-SecureString -SecureString $Context.ApiKey -AsPlainText - 'Accept' = 'application/json' - } + Headers = @{ 'Accept' = 'application/json' } SkipHttpErrorCheck = $true ErrorAction = 'Stop' } @@ -92,13 +88,13 @@ function Invoke-LovdataAPI { $params['Body'] = $Body | ConvertTo-Json -Depth 100 } - Write-Verbose "Sending [$Method] request to [$uri] using context [$($Context.ID)]." + Write-Verbose "Sending [$Method] request to [$uri]." $response = Invoke-WebRequest @params $statusCode = [int]$response.StatusCode $remaining = Get-LovdataResponseHeader -Headers $response.Headers -Name 'X-RateLimit-Remaining' if ($remaining) { - Write-Verbose "Lovdata rate limit remaining for this key: [$remaining]." + Write-Verbose "Lovdata rate limit remaining: [$remaining]." } $content = [string]$response.Content @@ -131,10 +127,6 @@ function Invoke-LovdataAPI { } $message = switch ($statusCode) { - 401 { - "The Lovdata API rejected the API key in context [$($Context.ID)] (401 Unauthorized). " + - "Confirm the key is current and that the Lovdata user holds the 'api' role, then reconnect with 'Connect-LovdataAccount'." - } 429 { $reset = Get-LovdataResponseHeader -Headers $response.Headers -Name 'X-RateLimit-Reset' $resetText = if ($null -ne ($reset -as [long])) { @@ -142,7 +134,7 @@ function Invoke-LovdataAPI { } else { '' } - "The Lovdata API rate limit for context [$($Context.ID)] is exhausted (429 Too Many Requests).$resetText" + "The Lovdata API rate limit is exhausted (429 Too Many Requests).$resetText" } default { "The Lovdata API request to [$uri] failed with status [$statusCode]." diff --git a/src/functions/private/API/Invoke-LovdataDownload.ps1 b/src/functions/private/API/Invoke-LovdataDownload.ps1 new file mode 100644 index 0000000..6f455bf --- /dev/null +++ b/src/functions/private/API/Invoke-LovdataDownload.ps1 @@ -0,0 +1,49 @@ +function Invoke-LovdataDownload { + <# + .SYNOPSIS + Stream a Lovdata download endpoint to a file on disk. + + .DESCRIPTION + Owns the binary download path the module uses, kept separate from the JSON transport in + Invoke-LovdataAPI because it streams the response straight to disk rather than buffering it in + memory. The Lovdata public data packages are tens of megabytes, so the body is written with + Invoke-WebRequest -OutFile and the transfer is reported on the progress stream. The endpoint is + open, so no authentication header is sent. + + .EXAMPLE + Invoke-LovdataDownload -Endpoint '/v1/publicData/get/gjeldende-lover.tar.bz2' -OutFile 'C:\data\gjeldende-lover.tar.bz2' + + Streams the package to the given file. + + .INPUTS + None + + .OUTPUTS + None + + .NOTES + Invoke-WebRequest raises a terminating error on an HTTP failure, which the caller is expected to surface. + + .LINK + https://api.lovdata.no/swagger/index.html + #> + [OutputType([void])] + [CmdletBinding()] + param( + # The download endpoint to call, relative to the configured base URI. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $Endpoint, + + # The full path of the file to stream the response into. + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string] $OutFile + ) + + $baseUri = (Get-LovdataConfig).ApiBaseUri + $uri = '{0}/{1}' -f $baseUri.TrimEnd('/'), $Endpoint.TrimStart('/') + + Write-Verbose "Streaming [$uri] to [$OutFile]." + Invoke-WebRequest -Uri $uri -OutFile $OutFile -ErrorAction Stop +} diff --git a/src/functions/private/Config/Initialize-LovdataConfig.ps1 b/src/functions/private/Config/Initialize-LovdataConfig.ps1 deleted file mode 100644 index 50068a6..0000000 --- a/src/functions/private/Config/Initialize-LovdataConfig.ps1 +++ /dev/null @@ -1,79 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } - -function Initialize-LovdataConfig { - <# - .SYNOPSIS - Load the Lovdata module configuration into memory. - - .DESCRIPTION - Reads the module-scoped configuration context from the Lovdata vault and caches it for the rest - of the session. The context is created from the module defaults the first time it is needed, and - settings that were added to the module after the context was stored are backfilled from those - defaults so an upgraded module never reads a half-populated configuration. - - .EXAMPLE - Initialize-LovdataConfig - - Loads the configuration, creating it from the module defaults when it does not exist yet. - - .EXAMPLE - Initialize-LovdataConfig -Force - - Discards the cached configuration and reads it from the vault again. - - .INPUTS - None - - .OUTPUTS - None - - .NOTES - The cached configuration lives in the module scope, so it is shared by every command in the session. - - .LINK - https://psmodule.io/Context/ - #> - [OutputType([void])] - [CmdletBinding()] - param( - # Reload the configuration from the vault instead of using the cached copy. - [Parameter()] - [switch] $Force - ) - - if (-not $Force -and $null -ne $script:Lovdata.Config) { - Write-Debug 'The Lovdata configuration is already loaded.' - return - } - - $vault = $script:Lovdata.ContextVault - $defaults = $script:Lovdata.DefaultConfig - $stored = Get-Context -ID $defaults.ID -Vault $vault - - if ($null -eq $stored) { - Write-Debug "Creating the Lovdata configuration context [$($defaults.ID)] in vault [$vault]." - $created = Set-Context -ID $defaults.ID -Context $defaults -Vault $vault -PassThru - $script:Lovdata.Config = [LovdataConfig]::new([pscustomobject]$created) - return - } - - $config = [LovdataConfig]::new([pscustomobject]$stored) - $config.ID = $defaults.ID - - # Settings introduced after the context was stored come back as $null. Fall back to the module - # defaults so the rest of the module never has to guard against a missing setting. - $backfilled = $false - foreach ($name in [LovdataConfig].GetProperties().Name) { - if ($null -eq $config.$name) { - $config.$name = $defaults.$name - $backfilled = $true - } - } - - if ($backfilled) { - Write-Debug 'Backfilling the stored Lovdata configuration with module defaults.' - $null = Set-Context -ID $config.ID -Context $config -Vault $vault - } - - $script:Lovdata.Config = $config -} diff --git a/src/functions/private/Context/Resolve-LovdataContext.ps1 b/src/functions/private/Context/Resolve-LovdataContext.ps1 deleted file mode 100644 index f5e651d..0000000 --- a/src/functions/private/Context/Resolve-LovdataContext.ps1 +++ /dev/null @@ -1,70 +0,0 @@ -function Resolve-LovdataContext { - <# - .SYNOPSIS - Turn a context reference into a usable Lovdata context. - - .DESCRIPTION - Accepts what a public command received on its Context parameter, which can be a context object, - a context name, or nothing at all, and returns the stored context it refers to. Nothing means the - default context. The result is checked for the API key and base URI the transport needs, so every - caller fails with the same actionable message instead of a raw HTTP error later on. - - .EXAMPLE - Resolve-LovdataContext -Context $null - - Returns the default context stored in the Lovdata vault. - - .EXAMPLE - Resolve-LovdataContext -Context 'production' - - Returns the context stored under the name 'production'. - - .INPUTS - None - - .OUTPUTS - LovdataContext - - .NOTES - This helper never picks a context on its own beyond falling back to the configured default. - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Get-LovdataContext/ - #> - [OutputType([LovdataContext])] - [CmdletBinding()] - param( - # The context reference passed to the calling command. A LovdataContext, a context name, or $null. - [Parameter(Mandatory)] - [AllowNull()] - [object] $Context - ) - - $resolved = if ($Context -is [LovdataContext]) { - $Context - } elseif ($Context -is [string]) { - if ([string]::IsNullOrWhiteSpace($Context)) { - Get-LovdataContext - } else { - Get-LovdataContext -Context $Context - } - } elseif ($null -ne $Context) { - [LovdataContext]::new([pscustomobject]$Context) - } else { - Get-LovdataContext - } - - if ($null -eq $resolved) { - throw "No Lovdata context was found. Run 'Connect-LovdataAccount' to store an API key." - } - - if ($null -eq $resolved.ApiKey -or $resolved.ApiKey.Length -eq 0) { - throw "The Lovdata context [$($resolved.ID)] has no API key. Run 'Connect-LovdataAccount' to store one." - } - - if ([string]::IsNullOrWhiteSpace($resolved.ApiBaseUri)) { - throw "The Lovdata context [$($resolved.ID)] has no API base URI. Reconnect it with 'Connect-LovdataAccount'." - } - - $resolved -} diff --git a/src/functions/public/Auth/Auth.md b/src/functions/public/Auth/Auth.md deleted file mode 100644 index fdd28c7..0000000 --- a/src/functions/public/Auth/Auth.md +++ /dev/null @@ -1,18 +0,0 @@ -# Authentication - -Lovdata authenticates with an API key sent in the `X-API-Key` header. Keys are issued by Lovdata to -users holding the `api` role — contact [api@lovdata.no](mailto:api@lovdata.no) to request one. - -`Connect-LovdataAccount` stores a key in an encrypted context in the `PSModule.Lovdata` vault, handled -by the [Context](https://psmodule.io/Context/) module. Every other command reads the key from there, so -a key is entered once and never appears in a script. - -Several keys can be stored side by side under different names — one per account or environment. One of -them is the default: `Connect-LovdataAccount` makes the first stored context the default, and -`Switch-LovdataContext` moves it. Any single command can still target another connection through its own -`-Context` parameter. `Get-LovdataContext` shows what is stored and `Disconnect-LovdataAccount` removes it. - -Removing a context deletes the local copy of the key; it does not revoke the key with Lovdata. Revoke a -leaked key with Lovdata as well. - -Settings that are not tied to a single key are managed with the [Config](../Config/Config.md) commands. diff --git a/src/functions/public/Auth/Connect-LovdataAccount.ps1 b/src/functions/public/Auth/Connect-LovdataAccount.ps1 deleted file mode 100644 index d1d4b38..0000000 --- a/src/functions/public/Auth/Connect-LovdataAccount.ps1 +++ /dev/null @@ -1,127 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } - -function Connect-LovdataAccount { - <# - .SYNOPSIS - Store a Lovdata API key so later commands can use it. - - .DESCRIPTION - Saves a Lovdata API key in an encrypted context and remembers which API it belongs to. Every - command that talks to Lovdata picks the key up from there, so scripts never have to carry it. - Storing more than one context keeps several accounts available at the same time; the first one - stored becomes the default, and Default moves the default to another one. - - Lovdata issues API keys to users holding the `api` role. The key is sent as the `X-API-Key` - header on every request. - - .EXAMPLE - Connect-LovdataAccount -ApiKey (Read-Host -Prompt 'Lovdata API key' -AsSecureString) - - Stores the key under the default context name. - - .EXAMPLE - Connect-LovdataAccount -ApiKey $key -Context 'production' -Default - - Stores the key as 'production' and makes it the context commands use by default. - - .EXAMPLE - Connect-LovdataAccount -ApiKey $key -ApiBaseUri $baseUri -PassThru - - Stores the key against an explicit API base URI, for a deployment other than the default one, - and returns the stored context. - - .INPUTS - None - - .OUTPUTS - LovdataContext - - .NOTES - The key is encrypted at rest by the Context module and is never written to the output stream. - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Connect-LovdataAccount/ - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Disconnect-LovdataAccount/ - - .LINK - https://api.lovdata.no/swagger/index.html - #> - [Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSAvoidUsingConvertToSecureStringWithPlainText', '', - Justification = 'A plain string key is accepted for convenience and converted before it is stored.' - )] - [OutputType([LovdataContext])] - [CmdletBinding(SupportsShouldProcess)] - param( - # The Lovdata API key, as a SecureString or as a string. - [Parameter(Mandatory, Position = 0)] - [ValidateScript( - { - ($_ -is [securestring] -and $_.Length -gt 0) -or - ($_ -is [string] -and -not [string]::IsNullOrWhiteSpace($_)) - }, - ErrorMessage = 'The API key must be a non-empty string or a non-empty SecureString.' - )] - [Alias('Key')] - [object] $ApiKey, - - # The name to store the connection under. - [Parameter()] - [ValidateNotNullOrEmpty()] - [Alias('Name')] - [string] $Context = 'default', - - # The base URI of the Lovdata API this key belongs to. Defaults to the module setting. - [Parameter()] - [ValidateNotNullOrEmpty()] - [string] $ApiBaseUri, - - # Make this the context commands use by default, replacing any existing default. - [Parameter()] - [switch] $Default, - - # Return the stored context. - [Parameter()] - [switch] $PassThru - ) - - Initialize-LovdataConfig - - if ($Context -eq $script:Lovdata.Config.ID) { - throw "The context name [$($script:Lovdata.Config.ID)] is reserved for the Lovdata module settings. Choose another name." - } - - if (-not $PSBoundParameters.ContainsKey('ApiBaseUri')) { - $ApiBaseUri = $script:Lovdata.Config.ApiBaseUri - } - - if (-not $PSCmdlet.ShouldProcess("Lovdata context [$Context]", 'Store the API key')) { - return - } - - $secureApiKey = if ($ApiKey -is [securestring]) { - $ApiKey - } else { - ConvertTo-SecureString -String $ApiKey -AsPlainText -Force - } - - $contextObject = [LovdataContext]@{ - ID = $Context - ApiBaseUri = $ApiBaseUri - AuthType = 'APIKey' - ApiKey = $secureApiKey - ConnectedAt = Get-Date - } - - $null = Set-Context -ID $Context -Context $contextObject -Vault $script:Lovdata.ContextVault - - if ($Default -or [string]::IsNullOrWhiteSpace($script:Lovdata.Config.DefaultContext)) { - Set-LovdataConfig -Name DefaultContext -Value $Context -Confirm:$false - } - - if ($PassThru) { - Get-LovdataContext -Context $Context - } -} diff --git a/src/functions/public/Auth/Disconnect-LovdataAccount.ps1 b/src/functions/public/Auth/Disconnect-LovdataAccount.ps1 deleted file mode 100644 index 2ac356e..0000000 --- a/src/functions/public/Auth/Disconnect-LovdataAccount.ps1 +++ /dev/null @@ -1,86 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } - -function Disconnect-LovdataAccount { - <# - .SYNOPSIS - Remove a stored Lovdata API key. - - .DESCRIPTION - Deletes a stored connection so the API key it holds is no longer on disk. Without arguments it - removes the context commands use by default. When the removed context was the default, the - default is cleared, so a later command asks for a new connection rather than silently using - another account. - - .EXAMPLE - Disconnect-LovdataAccount - - Removes the context commands use by default. - - .EXAMPLE - Disconnect-LovdataAccount -Context 'production' - - Removes the context stored under the name 'production'. - - .EXAMPLE - Get-LovdataContext -ListAvailable | Disconnect-LovdataAccount - - Removes every stored Lovdata connection. - - .INPUTS - LovdataContext - - .INPUTS - System.String - - .OUTPUTS - None - - .NOTES - Removing a context does not revoke the key with Lovdata. Revoke a leaked key with Lovdata as well. - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Disconnect-LovdataAccount/ - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Connect-LovdataAccount/ - #> - [OutputType([void])] - [CmdletBinding(SupportsShouldProcess)] - param( - # The context to remove, as a name or a context object. Defaults to the context commands use. - [Parameter(Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] - [Alias('ID', 'Name')] - [object] $Context - ) - - begin { - Initialize-LovdataConfig - } - - process { - $name = if ($Context -is [LovdataContext]) { - $Context.ID - } elseif ($Context -is [string] -and -not [string]::IsNullOrWhiteSpace($Context)) { - $Context - } else { - $script:Lovdata.Config.DefaultContext - } - - if ([string]::IsNullOrWhiteSpace($name)) { - throw "No default Lovdata context is configured. Name the context to remove with -Context." - } - - if ($name -eq $script:Lovdata.Config.ID) { - throw "The context name [$($script:Lovdata.Config.ID)] is reserved for the Lovdata module settings and cannot be removed." - } - - if ($PSCmdlet.ShouldProcess("Lovdata context [$name]", 'Remove the stored API key')) { - Remove-Context -ID $name -Vault $script:Lovdata.ContextVault - - if ($script:Lovdata.Config.DefaultContext -eq $name) { - Write-Verbose "Clearing [$name] as the default Lovdata context." - Set-LovdataConfig -Name DefaultContext -Value '' -Confirm:$false - } - } - } -} diff --git a/src/functions/public/Auth/Get-LovdataContext.ps1 b/src/functions/public/Auth/Get-LovdataContext.ps1 deleted file mode 100644 index efb1a5c..0000000 --- a/src/functions/public/Auth/Get-LovdataContext.ps1 +++ /dev/null @@ -1,90 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } - -function Get-LovdataContext { - <# - .SYNOPSIS - Get a stored Lovdata context. - - .DESCRIPTION - Returns the connections stored in the Lovdata vault. Without arguments it returns the context - commands use by default; with a name it returns that one; with ListAvailable it returns every - stored connection. The API key is part of the returned object but is held as a SecureString, so - it is never displayed. - - .EXAMPLE - Get-LovdataContext - - Returns the context commands use when none is given. - - .EXAMPLE - Get-LovdataContext -Context 'production' - - Returns the context stored under the name 'production'. - - .EXAMPLE - Get-LovdataContext -ListAvailable - - Returns every stored Lovdata connection. - - .INPUTS - None - - .OUTPUTS - LovdataContext - - .NOTES - The module's own settings context is excluded, so only real connections are returned. - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Get-LovdataContext/ - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Connect-LovdataAccount/ - #> - [OutputType([LovdataContext])] - [CmdletBinding(DefaultParameterSetName = 'As the default context')] - param( - # The name of the stored context to return. - [Parameter(Mandatory, Position = 0, ParameterSetName = 'By context name')] - [ValidateNotNullOrEmpty()] - [Alias('Name')] - [string] $Context, - - # Return every stored context instead of a single one. - [Parameter(Mandatory, ParameterSetName = 'As a list of every context')] - [switch] $ListAvailable - ) - - Initialize-LovdataConfig - $configID = $script:Lovdata.Config.ID - - $id = switch ($PSCmdlet.ParameterSetName) { - 'By context name' { - if ($Context -eq $configID) { - throw "The context name [$configID] is reserved for the Lovdata module settings." - } - $Context - } - 'As a list of every context' { - '*' - } - default { - if ([string]::IsNullOrWhiteSpace($script:Lovdata.Config.DefaultContext)) { - throw "No default Lovdata context is configured. Run 'Connect-LovdataAccount' to store an API key." - } - $script:Lovdata.Config.DefaultContext - } - } - - $contexts = @( - Get-Context -ID $id -Vault $script:Lovdata.ContextVault | - Where-Object { $_.ID -ne $configID } | - ForEach-Object { [LovdataContext]::new([pscustomobject]$_) } - ) - - if (-not $ListAvailable -and $contexts.Count -eq 0) { - throw "The Lovdata context [$id] was not found. Run 'Connect-LovdataAccount' to store it." - } - - $contexts | Sort-Object -Property ID -} diff --git a/src/functions/public/Auth/Switch-LovdataContext.ps1 b/src/functions/public/Auth/Switch-LovdataContext.ps1 deleted file mode 100644 index 24fe50d..0000000 --- a/src/functions/public/Auth/Switch-LovdataContext.ps1 +++ /dev/null @@ -1,67 +0,0 @@ -function Switch-LovdataContext { - <# - .SYNOPSIS - Choose which stored Lovdata connection commands use by default. - - .DESCRIPTION - Points the module at another stored connection, so commands that are not given a context use it - from then on. The context has to exist; switching to a name that was never stored fails rather - than leaving the module pointing at nothing. - - .EXAMPLE - Switch-LovdataContext -Context 'production' - - Makes 'production' the connection commands use by default. - - .EXAMPLE - Switch-LovdataContext -Context 'test' -PassThru - - Switches to 'test' and returns the context that is now in use. - - .INPUTS - LovdataContext - - .INPUTS - System.String - - .OUTPUTS - LovdataContext - - .NOTES - A single command can still target another connection with its own Context parameter. - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Switch-LovdataContext/ - - .LINK - https://psmodule.io/Lovdata/Functions/Auth/Get-LovdataContext/ - #> - [OutputType([LovdataContext])] - [CmdletBinding(SupportsShouldProcess)] - param( - # The context to use by default, as a name or a context object. - [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] - [ValidateNotNull()] - [Alias('ID', 'Name')] - [object] $Context, - - # Return the context that is now in use. - [Parameter()] - [switch] $PassThru - ) - - process { - $name = if ($Context -is [LovdataContext]) { $Context.ID } else { [string]$Context } - - # Resolving first means a typo fails here instead of on the next API call. - $target = Get-LovdataContext -Context $name - - if ($PSCmdlet.ShouldProcess("Lovdata context [$name]", 'Use as the default context')) { - Set-LovdataConfig -Name DefaultContext -Value $name -Confirm:$false - - if ($PassThru) { - $target - } - } - } -} diff --git a/src/functions/public/Config/Config.md b/src/functions/public/Config/Config.md index d89313d..2445495 100644 --- a/src/functions/public/Config/Config.md +++ b/src/functions/public/Config/Config.md @@ -1,8 +1,5 @@ # Configuration -Module-wide Lovdata settings: the API base URI new connections use, and the stored context that -commands fall back to when none is given. The settings live in their own context in the -`PSModule.Lovdata` vault, separate from any stored API key, so changing them never touches a -credential. - -Per-account credentials are managed with the [Auth](../Auth/Auth.md) commands. +Module-wide Lovdata settings for the current session, principally the API base URI every command sends +requests to. The settings are held in memory only and are never written to disk, because this module +stores no secret: everything the first release covers works with no account and no `API` key. diff --git a/src/functions/public/Config/Get-LovdataConfig.ps1 b/src/functions/public/Config/Get-LovdataConfig.ps1 index 0075179..a02608a 100644 --- a/src/functions/public/Config/Get-LovdataConfig.ps1 +++ b/src/functions/public/Config/Get-LovdataConfig.ps1 @@ -4,9 +4,10 @@ function Get-LovdataConfig { Get the module-wide Lovdata settings. .DESCRIPTION - Returns the settings that apply to the module as a whole rather than to a single stored API key: - the API base URI new connections use, and the name of the context commands fall back to when - none is given. The settings are created from the module defaults the first time they are read. + Returns the settings that apply to the module as a whole for the current session, principally the + API base URI every command sends requests to. The settings are held in memory only: they are + seeded from the module defaults the first time they are read and are not persisted, so a new + session starts from the defaults again. .EXAMPLE Get-LovdataConfig @@ -14,9 +15,9 @@ function Get-LovdataConfig { Returns the module-wide settings. .EXAMPLE - (Get-LovdataConfig).DefaultContext + (Get-LovdataConfig).ApiBaseUri - Returns the name of the context used by commands that are not given one. + Returns the API base URI the module sends requests to. .INPUTS None @@ -25,18 +26,23 @@ function Get-LovdataConfig { LovdataConfig .NOTES - Settings are stored in their own context and are never mixed with stored API keys. + The settings are session-scoped and never written to disk, because this module stores no secret. .LINK https://psmodule.io/Lovdata/Functions/Config/Get-LovdataConfig/ .LINK - https://psmodule.io/Context/ + https://psmodule.io/Lovdata/Functions/Config/Set-LovdataConfig/ #> [OutputType([LovdataConfig])] [CmdletBinding()] param() - Initialize-LovdataConfig + if ($null -eq $script:Lovdata.Config) { + $script:Lovdata.Config = [LovdataConfig]@{ + ApiBaseUri = $script:Lovdata.DefaultConfig.ApiBaseUri + } + } + $script:Lovdata.Config } diff --git a/src/functions/public/Config/Set-LovdataConfig.ps1 b/src/functions/public/Config/Set-LovdataConfig.ps1 index 40473eb..0115a5f 100644 --- a/src/functions/public/Config/Set-LovdataConfig.ps1 +++ b/src/functions/public/Config/Set-LovdataConfig.ps1 @@ -1,24 +1,22 @@ -#Requires -Modules @{ ModuleName = 'Context'; ModuleVersion = '8.1.6' } - function Set-LovdataConfig { <# .SYNOPSIS Change a module-wide Lovdata setting. .DESCRIPTION - Updates one of the settings that apply to the module as a whole and stores it so it survives the - session. Use it to point the module at a different API base URI, or to choose which stored - context commands fall back to when none is given. + Updates one of the settings that apply to the module as a whole for the current session. Use it to + point the module at a different API base URI, for example a test deployment. The change lives in + memory only and is not persisted, so a new session starts from the module defaults again. .EXAMPLE Set-LovdataConfig -Name ApiBaseUri -Value $baseUri - Points new connections at the given API base URI. + Points the module at the API base URI held in $baseUri. .EXAMPLE - Set-LovdataConfig -Name DefaultContext -Value 'production' -PassThru + Set-LovdataConfig -Name ApiBaseUri -Value $baseUri -PassThru - Makes 'production' the context commands use by default and returns the updated settings. + Points the module at the API base URI held in $baseUri and returns the updated settings. .INPUTS None @@ -27,8 +25,7 @@ function Set-LovdataConfig { LovdataConfig .NOTES - Changing ApiBaseUri does not move contexts that are already stored; those keep the base URI they - were connected with. + The change is session-scoped and never written to disk, because this module stores no secret. .LINK https://psmodule.io/Lovdata/Functions/Config/Set-LovdataConfig/ @@ -41,10 +38,10 @@ function Set-LovdataConfig { param( # The setting to change. [Parameter(Mandatory, Position = 0)] - [ValidateSet('ApiBaseUri', 'DefaultContext')] + [ValidateSet('ApiBaseUri')] [string] $Name, - # The new value of the setting. DefaultContext accepts an empty string to clear the default. + # The new value of the setting. [Parameter(Mandatory, Position = 1)] [AllowEmptyString()] [string] $Value, @@ -54,18 +51,17 @@ function Set-LovdataConfig { [switch] $PassThru ) - Initialize-LovdataConfig + $config = Get-LovdataConfig if ($Name -eq 'ApiBaseUri' -and [string]::IsNullOrWhiteSpace($Value)) { throw 'ApiBaseUri cannot be empty. Provide the base URI of the Lovdata API, for example https://api.lovdata.no.' } if ($PSCmdlet.ShouldProcess("Lovdata setting [$Name]", "Set to [$Value]")) { - $script:Lovdata.Config.$Name = $Value - $null = Set-Context -ID $script:Lovdata.Config.ID -Context $script:Lovdata.Config -Vault $script:Lovdata.ContextVault + $config.$Name = $Value if ($PassThru) { - $script:Lovdata.Config + $config } } } diff --git a/src/functions/public/LegalSources/Get-LovdataLegalSource.ps1 b/src/functions/public/LegalSources/Get-LovdataLegalSource.ps1 deleted file mode 100644 index 3f10673..0000000 --- a/src/functions/public/LegalSources/Get-LovdataLegalSource.ps1 +++ /dev/null @@ -1,89 +0,0 @@ -function Get-LovdataLegalSource { - <# - .SYNOPSIS - Get the legal sources available in Lovdata. - - .DESCRIPTION - Returns the legal sources that the connected account can reach. A legal source is one of the bases - Lovdata organises its documents into, such as acts and central regulations. Each source has the - identifier other commands use to address it and the description Lovdata gives it. Note that - Lovdata does not make every source in its databases available through the API. - - .EXAMPLE - Get-LovdataLegalSource - - Returns every legal source the connected account can reach. - - .EXAMPLE - Get-LovdataLegalSource -ID 'lov*' - - Returns the legal sources whose identifier starts with 'lov'. - - .EXAMPLE - Get-LovdataLegalSource -Context 'production' - - Returns the legal sources reachable with the API key stored as 'production'. - - .INPUTS - None - - .OUTPUTS - LovdataLegalSource - - .NOTES - Which sources are returned depends on the account, so the result can differ between contexts. - - .LINK - https://psmodule.io/Lovdata/Functions/LegalSources/Get-LovdataLegalSource/ - - .LINK - https://api.lovdata.no/swagger/index.html - #> - [OutputType([LovdataLegalSource])] - [CmdletBinding()] - param( - # The identifier of the legal sources to return. Supports wildcards. - [Parameter(Position = 0)] - [SupportsWildcards()] - [ValidateNotNullOrEmpty()] - [string] $ID = '*', - - # The connection to use, as a name or a context object. Defaults to the context commands use. - [Parameter()] - [object] $Context - ) - - $resolvedContext = Resolve-LovdataContext -Context $Context - $response = Invoke-LovdataAPI -Endpoint '/v1/legalSource/list' -Method Get -Context $resolvedContext - - # The API documents the entries as free-form objects, so accept the field names it is known to use. - $sources = foreach ($item in @($response)) { - if ($null -eq $item) { - continue - } - - $properties = $item.PSObject.Properties - $sourceID = '' - foreach ($name in 'id', 'base', 'name') { - if ($properties[$name]) { - $sourceID = [string]$properties[$name].Value - break - } - } - - $description = '' - foreach ($name in 'description', 'title') { - if ($properties[$name]) { - $description = [string]$properties[$name].Value - break - } - } - - [LovdataLegalSource]@{ - ID = $sourceID - Description = $description - } - } - - $sources | Where-Object { $_.ID -like $ID } | Sort-Object -Property ID -} diff --git a/src/functions/public/LegalSources/LegalSources.md b/src/functions/public/LegalSources/LegalSources.md deleted file mode 100644 index a42a15a..0000000 --- a/src/functions/public/LegalSources/LegalSources.md +++ /dev/null @@ -1,9 +0,0 @@ -# Legal sources - -Lovdata organises its documents into legal sources, also called bases: Norwegian acts (`lover`), -central regulations (`sentrale forskrifter`), and the other collections the foundation maintains. A -source identifier is what other commands use to address a collection, so listing the sources is -usually the first call in a script. - -Which sources come back depends on the account behind the API key — Lovdata does not expose every -source in its databases through the API, and access differs between accounts. diff --git a/src/functions/public/PublicData/Get-LovdataPublicDataset.ps1 b/src/functions/public/PublicData/Get-LovdataPublicDataset.ps1 new file mode 100644 index 0000000..bc619ed --- /dev/null +++ b/src/functions/public/PublicData/Get-LovdataPublicDataset.ps1 @@ -0,0 +1,70 @@ +function Get-LovdataPublicDataset { + <# + .SYNOPSIS + Get the free open data packages Lovdata publishes. + + .DESCRIPTION + Returns the public data packages Lovdata publishes as free open data under the Norwegian Licence + for Open Government Data (NLOD) 2.0, such as the current acts and central regulations. Each entry + carries the filename Save-LovdataPublicDataset downloads, plus the size and last-modified date so + a caller can decide what to fetch. No account and no API key are needed. + + Content retrieved through this command remains subject to Lovdata's terms; credit Lovdata as the + source when redistributing it. + + .EXAMPLE + Get-LovdataPublicDataset + + Returns every public data package Lovdata publishes. + + .EXAMPLE + Get-LovdataPublicDataset -FileName 'gjeldende-*' + + Returns the packages whose filename starts with 'gjeldende-'. + + .INPUTS + None + + .OUTPUTS + LovdataPublicDataset + + .NOTES + The packages are published under the Norwegian Licence for Open Government Data (NLOD) 2.0. + + .LINK + https://psmodule.io/Lovdata/Functions/PublicData/Get-LovdataPublicDataset/ + + .LINK + https://psmodule.io/Lovdata/Functions/PublicData/Save-LovdataPublicDataset/ + #> + [OutputType([LovdataPublicDataset])] + [CmdletBinding()] + param( + # The filename of the packages to return. Supports wildcards. + [Parameter(Position = 0)] + [SupportsWildcards()] + [ValidateNotNullOrEmpty()] + [string] $FileName = '*' + ) + + $response = Invoke-LovdataAPI -Endpoint '/v1/publicData/list' + + $datasets = foreach ($item in @($response)) { + if ($null -eq $item) { + continue + } + + [LovdataPublicDataset]@{ + FileName = [string]$item.filename + Description = [string]$item.description + SizeBytes = [long]$item.sizeBytes + LastModified = [datetime]::Parse( + [string]$item.lastModified, + [cultureinfo]::InvariantCulture, + [System.Globalization.DateTimeStyles]::RoundtripKind + ) + } + } + + $datasets | Where-Object { $_.FileName -like $FileName } | Sort-Object -Property FileName +} diff --git a/src/functions/public/PublicData/PublicData.md b/src/functions/public/PublicData/PublicData.md new file mode 100644 index 0000000..11ca33a --- /dev/null +++ b/src/functions/public/PublicData/PublicData.md @@ -0,0 +1,12 @@ +# Public data + +Lovdata publishes the current Norwegian acts (`gjeldende-lover.tar.bz2`) and central regulations +(`gjeldende-sentrale-forskrifter.tar.bz2`) as free open data. These commands list the packages and +download one to disk, with no account and no `API` key. + +## Licence and attribution + +The packages are published under the +[Norwegian Licence for Open Government Data (NLOD) 2.0](https://data.norge.no/nlod/en/2.0). Content +retrieved through these commands remains subject to Lovdata's terms; credit Lovdata as the source when +you redistribute it. This module is not affiliated with or endorsed by Lovdata. diff --git a/src/functions/public/PublicData/Save-LovdataPublicDataset.ps1 b/src/functions/public/PublicData/Save-LovdataPublicDataset.ps1 new file mode 100644 index 0000000..95d4724 --- /dev/null +++ b/src/functions/public/PublicData/Save-LovdataPublicDataset.ps1 @@ -0,0 +1,87 @@ +function Save-LovdataPublicDataset { + <# + .SYNOPSIS + Download a Lovdata public data package to disk. + + .DESCRIPTION + Downloads one of the free open data packages Lovdata publishes to a directory on disk. The + package is streamed straight to the file and the transfer is reported on the progress stream, + because the packages are large. No account and no API key are needed. An existing file is not + overwritten unless Force is given. The resulting file is returned so it can be piped on, for + example to an extraction step. + + The packages are published under the Norwegian Licence for Open Government Data (NLOD) 2.0. + Content retrieved through this command remains subject to Lovdata's terms; credit Lovdata as the + source when redistributing it. + + .EXAMPLE + Save-LovdataPublicDataset -FileName 'gjeldende-lover.tar.bz2' + + Downloads the current acts package into the current directory. + + .EXAMPLE + Get-LovdataPublicDataset -FileName 'gjeldende-*' | Save-LovdataPublicDataset -Path 'C:\lovdata' -Force + + Downloads every matching package into the given directory, overwriting any existing files. + + .INPUTS + LovdataPublicDataset + + .OUTPUTS + System.IO.FileInfo + + .NOTES + The packages are published under the Norwegian Licence for Open Government Data (NLOD) 2.0. + + .LINK + https://psmodule.io/Lovdata/Functions/PublicData/Save-LovdataPublicDataset/ + + .LINK + https://psmodule.io/Lovdata/Functions/PublicData/Get-LovdataPublicDataset/ + #> + [OutputType([System.IO.FileInfo])] + [CmdletBinding(SupportsShouldProcess)] + param( + # The filename of the package to download, as reported by Get-LovdataPublicDataset. + [Parameter(Mandatory, Position = 0, ValueFromPipelineByPropertyName)] + [ValidateNotNullOrEmpty()] + [ValidateScript( + { $_ -eq [System.IO.Path]::GetFileName($_) -and $_ -notin '.', '..' }, + ErrorMessage = "FileName must be a bare package filename with no path separators, for example 'gjeldende-lover.tar.bz2'." + )] + [string] $FileName, + + # The directory to download the package into. Defaults to the current directory. + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $Path = '.', + + # Overwrite an existing file instead of refusing. + [Parameter()] + [switch] $Force + ) + + process { + $directory = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue + if ($null -eq $directory) { + throw "The download directory [$Path] does not exist. Create it first, or pass an existing directory to -Path." + } + if (-not (Test-Path -LiteralPath $directory -PathType Container)) { + throw "The download path [$Path] is not a directory. Pass a directory to -Path." + } + + $target = Join-Path -Path $directory.ProviderPath -ChildPath $FileName + + if ((Test-Path -LiteralPath $target -PathType Leaf) -and -not $Force) { + throw "A file already exists at [$target]. Use -Force to overwrite it." + } + + if (-not $PSCmdlet.ShouldProcess($target, "Download Lovdata package [$FileName]")) { + return + } + + Invoke-LovdataDownload -Endpoint "/v1/publicData/get/$FileName" -OutFile $target + + Get-Item -LiteralPath $target + } +} diff --git a/src/functions/public/Service/Get-LovdataApiVersion.ps1 b/src/functions/public/Service/Get-LovdataApiVersion.ps1 new file mode 100644 index 0000000..7c5e1aa --- /dev/null +++ b/src/functions/public/Service/Get-LovdataApiVersion.ps1 @@ -0,0 +1,47 @@ +function Get-LovdataApiVersion { + <# + .SYNOPSIS + Get the version of the deployed Lovdata API service. + + .DESCRIPTION + Returns the deployment name, build timestamp, and source revision the Lovdata API reports. The + endpoint is open, so no account and no API key are needed. Use it to record which build a script + ran against, or as a first-run diagnostic alongside Test-LovdataConnection. + + .EXAMPLE + Get-LovdataApiVersion + + Returns the deployed API version. + + .EXAMPLE + (Get-LovdataApiVersion).Revision + + Returns the source revision the deployed API was built from. + + .INPUTS + None + + .OUTPUTS + LovdataApiVersion + + .NOTES + The build timestamp is returned as Lovdata reports it, for example '2026-07-31-1613'. + + .LINK + https://psmodule.io/Lovdata/Functions/Service/Get-LovdataApiVersion/ + + .LINK + https://psmodule.io/Lovdata/Functions/Service/Test-LovdataConnection/ + #> + [OutputType([LovdataApiVersion])] + [CmdletBinding()] + param() + + $response = Invoke-LovdataAPI -Endpoint '/version' + + [LovdataApiVersion]@{ + Name = [string]$response.name + Timestamp = [string]$response.timestamp + Revision = [string]$response.revision + } +} diff --git a/src/functions/public/Service/Service.md b/src/functions/public/Service/Service.md new file mode 100644 index 0000000..a669c9d --- /dev/null +++ b/src/functions/public/Service/Service.md @@ -0,0 +1,5 @@ +# Service + +Operational checks that answer two questions before a script starts pulling data: is the Lovdata API +reachable, and which build is deployed. Both endpoints are open, so these commands work with no account +and no `API` key, which makes them usable as a first-run diagnostic. diff --git a/src/functions/public/Service/Test-LovdataConnection.ps1 b/src/functions/public/Service/Test-LovdataConnection.ps1 new file mode 100644 index 0000000..0321844 --- /dev/null +++ b/src/functions/public/Service/Test-LovdataConnection.ps1 @@ -0,0 +1,49 @@ +function Test-LovdataConnection { + <# + .SYNOPSIS + Test whether the Lovdata API is reachable. + + .DESCRIPTION + Sends a request to the Lovdata service ping endpoint and returns whether it answered. The endpoint + is open, so no account and no API key are needed. The command never throws when the service is + unreachable; it writes a warning and returns false, so it is safe to use directly in a + conditional as a first-run diagnostic. + + .EXAMPLE + Test-LovdataConnection + + Returns $true when the Lovdata API answered, or $false when it did not. + + .EXAMPLE + if (Test-LovdataConnection) { Get-LovdataPublicDataset } + + Only lists the public data packages when the service is reachable. + + .INPUTS + None + + .OUTPUTS + System.Boolean + + .NOTES + A failure to reach the service is reported as $false with a warning rather than a terminating error. + + .LINK + https://psmodule.io/Lovdata/Functions/Service/Test-LovdataConnection/ + + .LINK + https://psmodule.io/Lovdata/Functions/Service/Get-LovdataApiVersion/ + #> + [OutputType([bool])] + [CmdletBinding()] + param() + + try { + $null = Invoke-LovdataAPI -Endpoint '/ping' + $true + } catch { + $reason = $_.Exception.Message + Write-Warning "The Lovdata API could not be reached: $reason" + $false + } +} diff --git a/src/variables/private/Lovdata.ps1 b/src/variables/private/Lovdata.ps1 index cfb71aa..0a2d131 100644 --- a/src/variables/private/Lovdata.ps1 +++ b/src/variables/private/Lovdata.ps1 @@ -1,14 +1,9 @@ $script:Lovdata = [pscustomobject]@{ - # The Context vault every Lovdata context is stored in. - ContextVault = 'PSModule.Lovdata' - - # The module-scoped settings used until the user changes them. + # The module-scoped settings used until the user changes them in this session. DefaultConfig = [LovdataConfig]@{ - ID = 'Module' - ApiBaseUri = 'https://api.lovdata.no' - DefaultContext = '' + ApiBaseUri = 'https://api.lovdata.no' } - # The configuration loaded from the vault, cached for the lifetime of the session. + # The in-memory settings for the current session. Seeded from DefaultConfig on first use. Config = $null } diff --git a/tests/Auth.Tests.ps1 b/tests/Auth.Tests.ps1 deleted file mode 100644 index 7ff92ac..0000000 --- a/tests/Auth.Tests.ps1 +++ /dev/null @@ -1,199 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } - -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', '', - Justification = 'Pester assigns shared state in BeforeAll and reads it inside It blocks.' -)] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSAvoidUsingConvertToSecureStringWithPlainText', '', - Justification = 'The fixed test key never leaves the throwaway test vault.' -)] -[CmdletBinding()] -param() - -Describe 'Auth' { - BeforeAll { - $script:testVault = . (Join-Path -Path $PSScriptRoot -ChildPath 'Lovdata.TestSetup.ps1') - } - - AfterAll { - Remove-ContextVault -Name $script:testVault -Confirm:$false -ErrorAction SilentlyContinue - } - - AfterEach { - Get-LovdataContext -ListAvailable | Disconnect-LovdataAccount -Confirm:$false - Set-LovdataConfig -Name DefaultContext -Value '' -Confirm:$false - } - - Context 'Connect-LovdataAccount' { - It 'stores the API key and makes the first connection the default' { - $context = Connect-LovdataAccount -ApiKey 'test-key' -Context 'first' -PassThru -Confirm:$false - - $context | Should -BeOfType [LovdataContext] - $context.ID | Should -Be 'first' - $context.AuthType | Should -Be 'APIKey' - $context.ApiBaseUri | Should -Be 'https://api.lovdata.no' - (Get-LovdataConfig).DefaultContext | Should -Be 'first' - } - - It 'keeps the API key as a secure string' { - $context = Connect-LovdataAccount -ApiKey 'test-key' -Context 'secure' -PassThru -Confirm:$false - - $context.ApiKey | Should -BeOfType [securestring] - ($context | Out-String) | Should -Not -Match 'test-key' - } - - It 'round-trips a secure string key through the store' { - $secureKey = ConvertTo-SecureString -String 'secure-key' -AsPlainText -Force - - $context = Connect-LovdataAccount -ApiKey $secureKey -Context 'roundtrip' -PassThru -Confirm:$false - - ConvertFrom-SecureString -SecureString $context.ApiKey -AsPlainText | Should -Be 'secure-key' - } - - It 'leaves an existing default alone unless Default is used' { - Connect-LovdataAccount -ApiKey 'test-key' -Context 'first' -Confirm:$false - Connect-LovdataAccount -ApiKey 'test-key' -Context 'second' -Confirm:$false - - (Get-LovdataConfig).DefaultContext | Should -Be 'first' - - Connect-LovdataAccount -ApiKey 'test-key' -Context 'second' -Default -Confirm:$false - - (Get-LovdataConfig).DefaultContext | Should -Be 'second' - } - - It 'stores the API base URI it was given' { - $context = Connect-LovdataAccount -ApiKey 'test-key' -Context 'custom' -ApiBaseUri 'https://api.example.test' -PassThru -Confirm:$false - - $context.ApiBaseUri | Should -Be 'https://api.example.test' - } - - It 'stores nothing when WhatIf is used' { - Connect-LovdataAccount -ApiKey 'test-key' -Context 'whatif' -WhatIf - - @(Get-LovdataContext -ListAvailable) | Should -HaveCount 0 - } - - It 'refuses the name reserved for the module settings' { - { Connect-LovdataAccount -ApiKey 'test-key' -Context 'Module' -Confirm:$false } | - Should -Throw '*reserved for the Lovdata module settings*' - } - - It 'rejects an empty API key' { - { Connect-LovdataAccount -ApiKey ' ' -Context 'empty' -Confirm:$false } | Should -Throw - { Connect-LovdataAccount -ApiKey ([securestring]::new()) -Context 'empty' -Confirm:$false } | Should -Throw - } - } - - Context 'Get-LovdataContext' { - BeforeEach { - Connect-LovdataAccount -ApiKey 'test-key' -Context 'alpha' -Confirm:$false - Connect-LovdataAccount -ApiKey 'test-key' -Context 'beta' -Confirm:$false - } - - It 'returns the default connection when none is named' { - (Get-LovdataContext).ID | Should -Be 'alpha' - } - - It 'returns a connection by name' { - (Get-LovdataContext -Context 'beta').ID | Should -Be 'beta' - } - - It 'lists every connection without the module settings' { - $contexts = @(Get-LovdataContext -ListAvailable) - - $contexts.ID | Should -Be @('alpha', 'beta') - $contexts.ID | Should -Not -Contain 'Module' - } - - It 'reports a name that was never stored' { - { Get-LovdataContext -Context 'missing' } | Should -Throw '*was not found*' - } - - It 'refuses to return the module settings as a connection' { - { Get-LovdataContext -Context 'Module' } | Should -Throw '*reserved for the Lovdata module settings*' - } - - It 'asks for a connection when no default is configured' { - Set-LovdataConfig -Name DefaultContext -Value '' -Confirm:$false - - { Get-LovdataContext } | Should -Throw '*No default Lovdata context is configured*' - } - } - - Context 'Switch-LovdataContext' { - BeforeEach { - Connect-LovdataAccount -ApiKey 'test-key' -Context 'alpha' -Confirm:$false - Connect-LovdataAccount -ApiKey 'test-key' -Context 'beta' -Confirm:$false - } - - It 'changes which connection commands use by default' { - Switch-LovdataContext -Context 'beta' -Confirm:$false - - (Get-LovdataConfig).DefaultContext | Should -Be 'beta' - (Get-LovdataContext).ID | Should -Be 'beta' - } - - It 'returns the connection now in use when PassThru is used' { - $context = Switch-LovdataContext -Context 'beta' -PassThru -Confirm:$false - - $context | Should -BeOfType [LovdataContext] - $context.ID | Should -Be 'beta' - } - - It 'refuses a connection that was never stored' { - { Switch-LovdataContext -Context 'missing' -Confirm:$false } | Should -Throw '*was not found*' - - (Get-LovdataConfig).DefaultContext | Should -Be 'alpha' - } - - It 'changes nothing when WhatIf is used' { - Switch-LovdataContext -Context 'beta' -WhatIf - - (Get-LovdataConfig).DefaultContext | Should -Be 'alpha' - } - } - - Context 'Disconnect-LovdataAccount' { - BeforeEach { - Connect-LovdataAccount -ApiKey 'test-key' -Context 'alpha' -Confirm:$false - Connect-LovdataAccount -ApiKey 'test-key' -Context 'beta' -Confirm:$false - } - - It 'removes a named connection and leaves the others' { - Disconnect-LovdataAccount -Context 'beta' -Confirm:$false - - @(Get-LovdataContext -ListAvailable).ID | Should -Be @('alpha') - } - - It 'removes the default connection and clears the default' { - Disconnect-LovdataAccount -Confirm:$false - - @(Get-LovdataContext -ListAvailable).ID | Should -Be @('beta') - (Get-LovdataConfig).DefaultContext | Should -BeNullOrEmpty - } - - It 'keeps the default when another connection is removed' { - Disconnect-LovdataAccount -Context 'beta' -Confirm:$false - - (Get-LovdataConfig).DefaultContext | Should -Be 'alpha' - } - - It 'removes every connection it is piped' { - Get-LovdataContext -ListAvailable | Disconnect-LovdataAccount -Confirm:$false - - @(Get-LovdataContext -ListAvailable) | Should -HaveCount 0 - } - - It 'removes nothing when WhatIf is used' { - Disconnect-LovdataAccount -Context 'beta' -WhatIf - - @(Get-LovdataContext -ListAvailable) | Should -HaveCount 2 - } - - It 'refuses to remove the module settings' { - { Disconnect-LovdataAccount -Context 'Module' -Confirm:$false } | - Should -Throw '*reserved for the Lovdata module settings*' - } - } -} diff --git a/tests/Config.Tests.ps1 b/tests/Config.Tests.ps1 index 62d230a..ac852c4 100644 --- a/tests/Config.Tests.ps1 +++ b/tests/Config.Tests.ps1 @@ -8,12 +8,8 @@ param() Describe 'Config' { - BeforeAll { - $script:testVault = . (Join-Path -Path $PSScriptRoot -ChildPath 'Lovdata.TestSetup.ps1') - } - - AfterAll { - Remove-ContextVault -Name $script:testVault -Confirm:$false -ErrorAction SilentlyContinue + BeforeEach { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Lovdata.TestSetup.ps1') } Context 'Get-LovdataConfig' { @@ -21,26 +17,21 @@ Describe 'Config' { $config = Get-LovdataConfig $config | Should -BeOfType [LovdataConfig] - $config.ID | Should -Be 'Module' $config.ApiBaseUri | Should -Be 'https://api.lovdata.no' - $config.DefaultContext | Should -BeNullOrEmpty } It 'returns the same settings on a later read' { - (Get-LovdataConfig).ApiBaseUri | Should -Be 'https://api.lovdata.no' + $first = Get-LovdataConfig + $first.ApiBaseUri = 'https://api.example.test' + + (Get-LovdataConfig).ApiBaseUri | Should -Be 'https://api.example.test' } } Context 'Set-LovdataConfig' { - AfterEach { - Set-LovdataConfig -Name ApiBaseUri -Value 'https://api.lovdata.no' -Confirm:$false - } - - It 'stores a changed setting so it survives a reload' { + It 'changes the API base URI for the session' { Set-LovdataConfig -Name ApiBaseUri -Value 'https://api.example.test' -Confirm:$false - InModuleScope -ModuleName Lovdata { Initialize-LovdataConfig -Force } - (Get-LovdataConfig).ApiBaseUri | Should -Be 'https://api.example.test' } diff --git a/tests/LegalSources.Tests.ps1 b/tests/LegalSources.Tests.ps1 deleted file mode 100644 index f3cd507..0000000 --- a/tests/LegalSources.Tests.ps1 +++ /dev/null @@ -1,92 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } - -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSUseDeclaredVarsMoreThanAssignments', '', - Justification = 'Pester assigns shared state in BeforeAll and reads it inside It blocks.' -)] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSReviewUnusedParameter', '', - Justification = 'Parameters are read by Pester mock parameter filters.' -)] -[CmdletBinding()] -param() - -Describe 'LegalSources' { - BeforeAll { - $script:testVault = . (Join-Path -Path $PSScriptRoot -ChildPath 'Lovdata.TestSetup.ps1') - Connect-LovdataAccount -ApiKey 'test-key' -Context 'test' -Confirm:$false - } - - AfterAll { - Get-LovdataContext -ListAvailable | Disconnect-LovdataAccount -Confirm:$false - Remove-ContextVault -Name $script:testVault -Confirm:$false -ErrorAction SilentlyContinue - } - - Context 'Get-LovdataLegalSource' { - BeforeEach { - Mock -ModuleName Lovdata -CommandName Invoke-LovdataAPI -MockWith { - @( - [pscustomobject]@{ id = 'sf'; description = 'Sentrale forskrifter' } - [pscustomobject]@{ id = 'lov'; description = 'Lover' } - [pscustomobject]@{ id = 'ltavd1'; description = 'Lovtidend avdeling I' } - ) - } - } - - It 'asks the API for the legal source list' { - Get-LovdataLegalSource - - Should -Invoke -ModuleName Lovdata -CommandName Invoke-LovdataAPI -Times 1 -Exactly -ParameterFilter { - $Endpoint -eq '/v1/legalSource/list' -and $Method -eq 'Get' - } - } - - It 'returns every source as a typed, sorted object' { - $sources = @(Get-LovdataLegalSource) - - $sources | Should -HaveCount 3 - $sources[0] | Should -BeOfType [LovdataLegalSource] - $sources.ID | Should -Be @('lov', 'ltavd1', 'sf') - ($sources | Where-Object ID -EQ 'lov').Description | Should -Be 'Lover' - } - - It 'filters the sources by identifier with wildcards' { - @(Get-LovdataLegalSource -ID 'lo*').ID | Should -Be @('lov') - @(Get-LovdataLegalSource -ID 'l*').ID | Should -Be @('lov', 'ltavd1') - @(Get-LovdataLegalSource -ID 'sf').ID | Should -Be @('sf') - } - - It 'returns nothing when no source matches' { - @(Get-LovdataLegalSource -ID 'nothing-matches-this') | Should -HaveCount 0 - } - - It 'uses the connection it is given' { - Connect-LovdataAccount -ApiKey 'other-key' -Context 'other' -Confirm:$false - - Get-LovdataLegalSource -Context 'other' - - Should -Invoke -ModuleName Lovdata -CommandName Invoke-LovdataAPI -Times 1 -Exactly -ParameterFilter { - $Context.ID -eq 'other' - } - - Disconnect-LovdataAccount -Context 'other' -Confirm:$false - } - - It 'accepts the field names the API is known to use' { - Mock -ModuleName Lovdata -CommandName Invoke-LovdataAPI -MockWith { - @([pscustomobject]@{ base = 'nl'; title = 'Norsk Lovtidend' }) - } - - $source = Get-LovdataLegalSource - - $source.ID | Should -Be 'nl' - $source.Description | Should -Be 'Norsk Lovtidend' - } - - It 'returns nothing when the API returns nothing' { - Mock -ModuleName Lovdata -CommandName Invoke-LovdataAPI -MockWith { $null } - - @(Get-LovdataLegalSource) | Should -HaveCount 0 - } - } -} diff --git a/tests/Lovdata.Internals.Tests.ps1 b/tests/Lovdata.Internals.Tests.ps1 index 0c65d3a..6bedc11 100644 --- a/tests/Lovdata.Internals.Tests.ps1 +++ b/tests/Lovdata.Internals.Tests.ps1 @@ -8,68 +8,55 @@ 'PSReviewUnusedParameter', '', Justification = 'Parameters are read by Pester mock parameter filters.' )] -[Diagnostics.CodeAnalysis.SuppressMessageAttribute( - 'PSAvoidUsingConvertToSecureStringWithPlainText', '', - Justification = 'The fixed test key never leaves the mocked request boundary.' -)] [CmdletBinding()] param() Describe 'Lovdata internals' { - BeforeAll { - $script:testVault = . (Join-Path -Path $PSScriptRoot -ChildPath 'Lovdata.TestSetup.ps1') - } - - AfterAll { - Remove-ContextVault -Name $script:testVault -Confirm:$false -ErrorAction SilentlyContinue + BeforeEach { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Lovdata.TestSetup.ps1') + InModuleScope -ModuleName Lovdata { + $script:Lovdata.Config = [LovdataConfig]@{ ApiBaseUri = 'https://api.example.test' } + } } Context 'Types' { It 'exports the module types as type accelerators' { [LovdataConfig] | Should -Not -BeNullOrEmpty - [LovdataContext] | Should -Not -BeNullOrEmpty - [LovdataLegalSource] | Should -Not -BeNullOrEmpty + [LovdataPublicDataset] | Should -Not -BeNullOrEmpty + [LovdataApiVersion] | Should -Not -BeNullOrEmpty } - It 'builds a context from an object and ignores fields it does not know' { - $context = [LovdataContext]::new([pscustomobject]@{ - ID = 'demo' - ApiBaseUri = 'https://api.example.test' - AuthType = 'APIKey' + It 'builds a dataset from an object and ignores fields it does not know' { + $dataset = [LovdataPublicDataset]::new([pscustomobject]@{ + FileName = 'gjeldende-lover.tar.bz2' + SizeBytes = 10 Unsupported = 'ignored' }) - $context.ID | Should -Be 'demo' - $context.ApiBaseUri | Should -Be 'https://api.example.test' - "$context" | Should -Be 'demo' + $dataset.FileName | Should -Be 'gjeldende-lover.tar.bz2' + $dataset.SizeBytes | Should -Be 10 + "$dataset" | Should -Be 'gjeldende-lover.tar.bz2' } } Context 'Invoke-LovdataAPI' { - It 'sends the API key as the X-API-Key header and deserializes the response' { + It 'builds the request URI from the configured base URI and deserializes the response' { InModuleScope -ModuleName Lovdata { Mock Invoke-WebRequest { [pscustomobject]@{ StatusCode = 200 - Content = '[{"id":"lov","description":"Lover"}]' + Content = '[{"filename":"gjeldende-lover.tar.bz2"}]' Headers = @{ 'X-RateLimit-Remaining' = @('199') } } } - $context = [LovdataContext]@{ - ID = 'demo' - ApiBaseUri = 'https://api.example.test' - AuthType = 'APIKey' - ApiKey = ConvertTo-SecureString -String 'secret-key' -AsPlainText -Force - } - - $result = Invoke-LovdataAPI -Endpoint '/v1/legalSource/list' -Context $context + $result = Invoke-LovdataAPI -Endpoint '/v1/publicData/list' - $result.id | Should -Be 'lov' + $result.filename | Should -Be 'gjeldende-lover.tar.bz2' Should -Invoke Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Method -eq 'Get' -and - $Uri -eq 'https://api.example.test/v1/legalSource/list' -and - $Headers['X-API-Key'] -eq 'secret-key' + $Uri -eq 'https://api.example.test/v1/publicData/list' -and + -not $Headers.ContainsKey('X-API-Key') } } } @@ -80,13 +67,7 @@ Describe 'Lovdata internals' { [pscustomobject]@{ StatusCode = 200; Content = '{}'; Headers = @{} } } - $context = [LovdataContext]@{ - ID = 'demo' - ApiBaseUri = 'https://api.example.test/' - ApiKey = ConvertTo-SecureString -String 'secret-key' -AsPlainText -Force - } - - Invoke-LovdataAPI -Endpoint 'v1/search' -Query @{ q = 'lov test'; limit = 10; offset = $null } -Context $context + Invoke-LovdataAPI -Endpoint 'v1/search' -Query @{ q = 'lov test'; limit = 10; offset = $null } Should -Invoke Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { $Uri -eq 'https://api.example.test/v1/search?limit=10&q=lov%20test' @@ -100,34 +81,7 @@ Describe 'Lovdata internals' { [pscustomobject]@{ StatusCode = 200; Content = 'Pong'; Headers = @{} } } - $context = [LovdataContext]@{ - ID = 'demo' - ApiBaseUri = 'https://api.example.test' - ApiKey = ConvertTo-SecureString -String 'secret-key' -AsPlainText -Force - } - - Invoke-LovdataAPI -Endpoint '/ping' -Context $context | Should -Be 'Pong' - } - } - - It 'explains a rejected API key' { - InModuleScope -ModuleName Lovdata { - Mock Invoke-WebRequest { - [pscustomobject]@{ - StatusCode = 401 - Content = '{"status":401,"message":"Unauthorized","detail":"Missing api role"}' - Headers = @{} - } - } - - $context = [LovdataContext]@{ - ID = 'demo' - ApiBaseUri = 'https://api.example.test' - ApiKey = ConvertTo-SecureString -String 'secret-key' -AsPlainText -Force - } - - { Invoke-LovdataAPI -Endpoint '/v1/legalSource/list' -Context $context } | - Should -Throw '*rejected the API key in context*demo*Missing api role*' + Invoke-LovdataAPI -Endpoint '/ping' | Should -Be 'Pong' } } @@ -141,14 +95,8 @@ Describe 'Lovdata internals' { } } - $context = [LovdataContext]@{ - ID = 'demo' - ApiBaseUri = 'https://api.example.test' - ApiKey = ConvertTo-SecureString -String 'secret-key' -AsPlainText -Force - } - - { Invoke-LovdataAPI -Endpoint '/v1/search' -Context $context } | - Should -Throw '*rate limit for context*demo*is exhausted*The limit resets at*' + { Invoke-LovdataAPI -Endpoint '/v1/search' } | + Should -Throw '*rate limit is exhausted*The limit resets at*' } } @@ -158,96 +106,40 @@ Describe 'Lovdata internals' { [pscustomobject]@{ StatusCode = 500; Content = ''; Headers = @{} } } - $context = [LovdataContext]@{ - ID = 'demo' - ApiBaseUri = 'https://api.example.test' - ApiKey = ConvertTo-SecureString -String 'secret-key' -AsPlainText -Force - } - - { Invoke-LovdataAPI -Endpoint '/v1/search' -Context $context } | + { Invoke-LovdataAPI -Endpoint '/v1/search' } | Should -Throw '*https://api.example.test/v1/search*failed with status*500*' } } } - Context 'Get-LovdataResponseHeader' { - It 'reads a header regardless of how it is cased' { + Context 'Invoke-LovdataDownload' { + It 'streams the configured download URI to the given file' { InModuleScope -ModuleName Lovdata { - $headers = @{ 'X-RateLimit-Remaining' = @('42') } + Mock Invoke-WebRequest {} - Get-LovdataResponseHeader -Headers $headers -Name 'x-ratelimit-remaining' | Should -Be '42' - } - } + Invoke-LovdataDownload -Endpoint '/v1/publicData/get/gjeldende-lover.tar.bz2' -OutFile 'TestDrive:\out.bz2' - It 'returns nothing for a header the response does not carry' { - InModuleScope -ModuleName Lovdata { - Get-LovdataResponseHeader -Headers @{} -Name 'X-RateLimit-Reset' | Should -BeNullOrEmpty - Get-LovdataResponseHeader -Headers $null -Name 'X-RateLimit-Reset' | Should -BeNullOrEmpty + Should -Invoke Invoke-WebRequest -Times 1 -Exactly -ParameterFilter { + $Uri -eq 'https://api.example.test/v1/publicData/get/gjeldende-lover.tar.bz2' -and + $OutFile -eq 'TestDrive:\out.bz2' + } } } } - Context 'Resolve-LovdataContext' { - BeforeEach { - Connect-LovdataAccount -ApiKey 'test-key' -Context 'alpha' -Confirm:$false - Connect-LovdataAccount -ApiKey 'test-key' -Context 'beta' -Confirm:$false - } - - AfterEach { - Get-LovdataContext -ListAvailable | Disconnect-LovdataAccount -Confirm:$false - Set-LovdataConfig -Name DefaultContext -Value '' -Confirm:$false - } - - It 'falls back to the default connection' { - InModuleScope -ModuleName Lovdata { - (Resolve-LovdataContext -Context $null).ID | Should -Be 'alpha' - (Resolve-LovdataContext -Context '').ID | Should -Be 'alpha' - } - } - - It 'resolves a connection by name' { - InModuleScope -ModuleName Lovdata { - (Resolve-LovdataContext -Context 'beta').ID | Should -Be 'beta' - } - } - - It 'passes an already resolved connection straight through' { - InModuleScope -ModuleName Lovdata { - $context = Get-LovdataContext -Context 'beta' - - (Resolve-LovdataContext -Context $context).ID | Should -Be 'beta' - } - } - - It 'asks for a key when the connection has none' { - InModuleScope -ModuleName Lovdata { - $context = [LovdataContext]@{ ID = 'keyless'; ApiBaseUri = 'https://api.example.test' } - - { Resolve-LovdataContext -Context $context } | Should -Throw '*has no API key*' - } - } - - It 'asks for a base URI when the connection has none' { + Context 'Get-LovdataResponseHeader' { + It 'reads a header regardless of how it is cased' { InModuleScope -ModuleName Lovdata { - $context = [LovdataContext]@{ - ID = 'uriless' - ApiKey = ConvertTo-SecureString -String 'secret-key' -AsPlainText -Force - } + $headers = @{ 'X-RateLimit-Remaining' = @('42') } - { Resolve-LovdataContext -Context $context } | Should -Throw '*has no API base URI*' + Get-LovdataResponseHeader -Headers $headers -Name 'x-ratelimit-remaining' | Should -Be '42' } } - } - Context 'Initialize-LovdataConfig' { - It 'backfills settings that a stored configuration predates' { + It 'returns nothing for a header the response does not carry' { InModuleScope -ModuleName Lovdata { - $null = Set-Context -ID 'Module' -Context ([pscustomobject]@{ ID = 'Module' }) -Vault $script:Lovdata.ContextVault - - Initialize-LovdataConfig -Force - - $script:Lovdata.Config.ApiBaseUri | Should -Be 'https://api.lovdata.no' - (Get-Context -ID 'Module' -Vault $script:Lovdata.ContextVault).ApiBaseUri | Should -Be 'https://api.lovdata.no' + Get-LovdataResponseHeader -Headers @{} -Name 'X-RateLimit-Reset' | Should -BeNullOrEmpty + Get-LovdataResponseHeader -Headers $null -Name 'X-RateLimit-Reset' | Should -BeNullOrEmpty } } } diff --git a/tests/Lovdata.TestSetup.ps1 b/tests/Lovdata.TestSetup.ps1 index 78a081b..61d691e 100644 --- a/tests/Lovdata.TestSetup.ps1 +++ b/tests/Lovdata.TestSetup.ps1 @@ -5,29 +5,22 @@ Shared setup for the Lovdata test suites. .DESCRIPTION - Points the imported Lovdata module at a throwaway Context vault so tests exercise the real store - without touching the vault a developer or runner already has, and returns the vault name so the - calling suite can remove it again. + Resets the imported Lovdata module's in-memory settings back to the module defaults so each suite + starts from a known state. The module keeps no vault and no secret, so there is nothing to clean up + afterwards. .EXAMPLE - $vault = . "$PSScriptRoot/Lovdata.TestSetup.ps1" + . "$PSScriptRoot/Lovdata.TestSetup.ps1" .INPUTS None .OUTPUTS - System.String + None #> [CmdletBinding()] param() -$testVault = "PSModule.Lovdata.Tests.$([guid]::NewGuid().Guid)" - -InModuleScope -ModuleName Lovdata -Parameters @{ Vault = $testVault } -ScriptBlock { - param($Vault) - - $script:Lovdata.ContextVault = $Vault +InModuleScope -ModuleName Lovdata { $script:Lovdata.Config = $null } - -$testVault diff --git a/tests/PublicData.Tests.ps1 b/tests/PublicData.Tests.ps1 new file mode 100644 index 0000000..d9e7e1b --- /dev/null +++ b/tests/PublicData.Tests.ps1 @@ -0,0 +1,131 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Pester assigns shared state in BeforeAll and reads it inside It blocks.' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSReviewUnusedParameter', '', + Justification = 'Parameters are read by Pester mock parameter filters.' +)] +[CmdletBinding()] +param() + +Describe 'PublicData' { + BeforeEach { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Lovdata.TestSetup.ps1') + } + + Context 'Get-LovdataPublicDataset' { + It 'returns the packages as typed objects with no API key stored' { + Mock -ModuleName Lovdata Invoke-LovdataAPI { + @( + [pscustomobject]@{ + filename = 'gjeldende-lover.tar.bz2' + description = 'Gjeldende lover, ajourfort med endringer' + sizeBytes = '5842472' + lastModified = '2026-08-01T01:31:00Z' + } + ) + } + + $result = Get-LovdataPublicDataset + + $result | Should -BeOfType [LovdataPublicDataset] + $result.FileName | Should -Be 'gjeldende-lover.tar.bz2' + $result.SizeBytes | Should -Be 5842472 + $result.SizeBytes | Should -BeOfType [long] + $result.LastModified | Should -BeOfType [datetime] + $result.LastModified.Kind | Should -Be ([System.DateTimeKind]::Utc) + $result.LastModified | Should -Be ([datetime]::new(2026, 8, 1, 1, 31, 0, [System.DateTimeKind]::Utc)) + Should -Invoke -ModuleName Lovdata Invoke-LovdataAPI -Times 1 -Exactly -ParameterFilter { + $Endpoint -eq '/v1/publicData/list' + } + } + + It 'filters the packages by filename wildcard' { + Mock -ModuleName Lovdata Invoke-LovdataAPI { + $modified = '2026-08-01T01:31:00Z' + @( + [pscustomobject]@{ filename = 'gjeldende-lover.tar.bz2'; sizeBytes = '1'; lastModified = $modified } + [pscustomobject]@{ filename = 'gjeldende-sentrale-forskrifter.tar.bz2'; sizeBytes = '2'; lastModified = $modified } + [pscustomobject]@{ filename = 'historiske-lover.tar.bz2'; sizeBytes = '3'; lastModified = $modified } + ) + } + + $result = Get-LovdataPublicDataset -FileName 'gjeldende-*' + + $result.FileName | Should -Be @('gjeldende-lover.tar.bz2', 'gjeldende-sentrale-forskrifter.tar.bz2') + } + } + + Context 'Save-LovdataPublicDataset' { + BeforeEach { + $script:downloadDir = Join-Path -Path $TestDrive -ChildPath ([guid]::NewGuid().Guid) + $null = New-Item -Path $script:downloadDir -ItemType Directory + } + + It 'downloads a package into the target directory and returns the file' { + Mock -ModuleName Lovdata Invoke-LovdataDownload { + Set-Content -Path $OutFile -Value 'data' + } + + $result = Save-LovdataPublicDataset -FileName 'gjeldende-lover.tar.bz2' -Path $script:downloadDir + + $result | Should -BeOfType [System.IO.FileInfo] + $result.Name | Should -Be 'gjeldende-lover.tar.bz2' + Should -Invoke -ModuleName Lovdata Invoke-LovdataDownload -Times 1 -Exactly -ParameterFilter { + $Endpoint -eq '/v1/publicData/get/gjeldende-lover.tar.bz2' -and + $OutFile -eq (Join-Path -Path $script:downloadDir -ChildPath 'gjeldende-lover.tar.bz2') + } + } + + It 'refuses to overwrite an existing file without Force' { + Mock -ModuleName Lovdata Invoke-LovdataDownload {} + $existing = Join-Path -Path $script:downloadDir -ChildPath 'gjeldende-lover.tar.bz2' + Set-Content -Path $existing -Value 'old' + + { Save-LovdataPublicDataset -FileName 'gjeldende-lover.tar.bz2' -Path $script:downloadDir } | + Should -Throw '*already exists*' + Should -Invoke -ModuleName Lovdata Invoke-LovdataDownload -Times 0 -Exactly + } + + It 'overwrites an existing file when Force is given' { + Mock -ModuleName Lovdata Invoke-LovdataDownload { + Set-Content -Path $OutFile -Value 'new' + } + $existing = Join-Path -Path $script:downloadDir -ChildPath 'gjeldende-lover.tar.bz2' + Set-Content -Path $existing -Value 'old' + + Save-LovdataPublicDataset -FileName 'gjeldende-lover.tar.bz2' -Path $script:downloadDir -Force + Should -Invoke -ModuleName Lovdata Invoke-LovdataDownload -Times 1 -Exactly + } + + It 'downloads nothing when WhatIf is used' { + Mock -ModuleName Lovdata Invoke-LovdataDownload {} + + Save-LovdataPublicDataset -FileName 'gjeldende-lover.tar.bz2' -Path $script:downloadDir -WhatIf + Should -Invoke -ModuleName Lovdata Invoke-LovdataDownload -Times 0 -Exactly + } + + It 'rejects a FileName that contains a path and never downloads' { + Mock -ModuleName Lovdata Invoke-LovdataDownload {} + + { Save-LovdataPublicDataset -FileName '../escape.txt' -Path $script:downloadDir } | + Should -Throw '*bare package filename*' + Should -Invoke -ModuleName Lovdata Invoke-LovdataDownload -Times 0 -Exactly + } + + It 'accepts a dataset from the pipeline by property name' { + Mock -ModuleName Lovdata Invoke-LovdataDownload { + Set-Content -Path $OutFile -Value 'data' + } + $dataset = [LovdataPublicDataset]@{ FileName = 'gjeldende-lover.tar.bz2' } + + $result = $dataset | Save-LovdataPublicDataset -Path $script:downloadDir + + $result.Name | Should -Be 'gjeldende-lover.tar.bz2' + Should -Invoke -ModuleName Lovdata Invoke-LovdataDownload -Times 1 -Exactly + } + } +} diff --git a/tests/Service.Tests.ps1 b/tests/Service.Tests.ps1 new file mode 100644 index 0000000..c429503 --- /dev/null +++ b/tests/Service.Tests.ps1 @@ -0,0 +1,48 @@ +#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '6.0.0'; MaximumVersion = '6.*' } + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', '', + Justification = 'Pester assigns shared state in BeforeAll and reads it inside It blocks.' +)] +[CmdletBinding()] +param() + +Describe 'Service' { + BeforeEach { + . (Join-Path -Path $PSScriptRoot -ChildPath 'Lovdata.TestSetup.ps1') + } + + Context 'Test-LovdataConnection' { + It 'returns true when the service answers' { + Mock -ModuleName Lovdata Invoke-LovdataAPI { 'Pong' } + + Test-LovdataConnection | Should -BeTrue + } + + It 'returns false and warns instead of throwing when the service is unreachable' { + Mock -ModuleName Lovdata Invoke-LovdataAPI { throw 'connection refused' } + + $result = Test-LovdataConnection -WarningAction SilentlyContinue + + $result | Should -BeFalse + } + } + + Context 'Get-LovdataApiVersion' { + It 'maps the reported version onto a typed object' { + Mock -ModuleName Lovdata Invoke-LovdataAPI { + [pscustomobject]@{ name = 'lovdata-api'; timestamp = '2026-07-31-1613'; revision = '3806f92' } + } + + $version = Get-LovdataApiVersion + + $version | Should -BeOfType [LovdataApiVersion] + $version.Name | Should -Be 'lovdata-api' + $version.Timestamp | Should -Be '2026-07-31-1613' + $version.Revision | Should -Be '3806f92' + Should -Invoke -ModuleName Lovdata Invoke-LovdataAPI -Times 1 -Exactly -ParameterFilter { + $Endpoint -eq '/version' + } + } + } +}